use serde_json::Value;
use trust_tasks_rs::{AsyncDispatcher, TrustTask};
use super::policy as room_policy;
use crate::routing::trust_task_admission::{RoomTraffic, admit_room};
use crate::server::AppState;
use crate::trust_tasks::helpers::{TrustTaskOutcome, app_error_to_reject, success_response};
use vti_common::audit::{AuditEvent, RoomOperationData};
use vti_rooms::audit::{self as rooms_audit, RoomOperation};
use vti_rooms::authz::{self, Action};
use vti_rooms::storage;
use vti_rooms::wire::{
ChainBody, ChainResponse, ClaimOwnerBody, CommitsBody, CommitsResponse, CreateRoomBody,
CreateRoomResponse, CurateRecordBody, CurateRecordResponse, GetRecordBody, GetRecordResponse,
ListRecordsBody, ListRecordsResponse, MintEpochBody, MintEpochResponse, OwnerResponse,
PruneBody, PruneResponse, PutRecordBody, PutRecordResponse, TransferOwnerBody,
};
use vti_rooms::{Record, RecordStatus, Room};
use vti_rooms_dtg::{DataIntegrityKeys, DtgChainVerifier, nomination};
static ROOMS: std::sync::LazyLock<AsyncDispatcher<RoomCtx, TrustTaskOutcome>> =
std::sync::LazyLock::new(dispatcher);
#[must_use]
pub(crate) fn serves(type_uri: &str) -> bool {
ROOMS.registered_uris().contains(&type_uri) || REVOCATION_URIS.contains(&type_uri)
}
const REVOCATION_URIS: &[&str] = &[
vti_rooms::revocation_tasks::PUT_TYPE,
vti_rooms::revocation_tasks::GET_TYPE,
];
#[must_use]
pub(crate) fn served_uris() -> Vec<&'static str> {
let mut uris = ROOMS.registered_uris();
uris.extend_from_slice(REVOCATION_URIS);
uris
}
pub(crate) async fn dispatch(
state: &AppState,
doc: TrustTask<Value>,
presenter: &str,
) -> TrustTaskOutcome {
let ctx = RoomCtx {
state: state.clone(),
presenter: presenter.to_string(),
};
let type_uri = doc.type_uri.to_string();
if REVOCATION_URIS.contains(&type_uri.as_str()) {
let put = type_uri == vti_rooms::revocation_tasks::PUT_TYPE;
return super::files::handle_revocations(doc, ctx, put).await;
}
match ROOMS
.dispatch_or_reject(doc, ctx, format!("urn:uuid:{}", uuid::Uuid::new_v4()))
.await
{
Ok(outcome) => outcome,
Err(err_doc) => crate::trust_tasks::helpers::error_response(err_doc),
}
}
#[derive(Clone)]
pub(crate) struct RoomCtx {
pub state: AppState,
pub presenter: String,
}
pub(crate) fn dispatcher() -> AsyncDispatcher<RoomCtx, TrustTaskOutcome> {
AsyncDispatcher::new()
.on_async(handle_create)
.on_async(handle_put_record)
.on_async(handle_get_record)
.on_async(handle_list_records)
.on_async(handle_curate_record)
.on_async(handle_mint_epoch)
.on_async(handle_epoch_chain)
.on_async(handle_epoch_prune)
.on_async(handle_epoch_commits)
.on_async(handle_transfer_owner)
.on_async(handle_claim_owner)
.on_async(super::files::handle_put_record_v2)
.on_async(super::files::handle_get_record_v2)
.on_async(super::files::handle_list_records_v2)
.on_async(super::files::handle_upload_begin)
.on_async(super::files::handle_upload_chunk)
.on_async(super::files::handle_upload_commit)
.on_async(super::files::handle_upload_abort)
.on_async(super::files::handle_blob_get)
.on_async(super::files::handle_download_chunk)
.on_async(super::files::handle_info)
.on_async(super::files::handle_usage)
.on_async(super::files::handle_upload_begin_v2)
.on_async(super::files::handle_upload_chunk_v2)
.on_async(super::files::handle_upload_commit_v2)
.on_async(super::files::handle_upload_abort_v2)
.on_async(super::files::handle_blob_get_v2)
}
pub(super) fn charged(
room_id: &str,
presenter: &str,
traffic: RoomTraffic,
) -> Result<(), TrustTaskOutcome> {
if admit_room(room_id, presenter, traffic) {
Ok(())
} else {
Err(TrustTaskOutcome {
status: axum::http::StatusCode::TOO_MANY_REQUESTS,
body: Vec::new(),
})
}
}
pub(super) fn presenter_and_verifier(
state: &AppState,
presenter: &str,
) -> (String, DtgChainVerifier) {
(
presenter.to_string(),
DtgChainVerifier::without_zk(Box::new(DataIntegrityKeys(state.trust_task_vm_resolver()))),
)
}
pub(super) async fn audit_room(
state: &AppState,
room: &Room,
authorized: &authz::AuthorizedAction,
operation: RoomOperation,
record_key: Option<&str>,
) {
let Some(writer) = state.audit_writer.as_ref() else {
return;
};
let entry = rooms_audit::for_operation(room.visibility, authorized, record_key);
let action = operation.action_name();
if let Err(e) = writer
.write(
entry.actor.as_str(),
None,
AuditEvent::RoomOperation(RoomOperationData {
room_id: entry.room_id,
action: action.to_string(),
record_key: entry.record_key,
visibility: format!("{:?}", room.visibility).to_lowercase(),
}),
)
.await
{
tracing::error!(error = %e, action, "failed to record a room audit entry");
}
}
pub(super) fn now() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
const DEFAULT_RETENTION_DAYS: u32 = 90;
const EPOCH_LIFETIME_DAYS_SECS: u64 =
vti_rooms::lifecycle::DEFAULT_EPOCH_LIFETIME_DAYS as u64 * 24 * 60 * 60;
async fn govern_creation(
state: &AppState,
authorized: &authz::AuthorizedCreate,
visibility: vti_rooms::Visibility,
retention_days: Option<u32>,
) -> Result<(), vti_common::error::AppError> {
let member = crate::members::storage::get_member(&state.members_ks, authorized.owner_did())
.await
.unwrap_or(None);
let role = crate::ceremony::assemble::load_actor_role(state, authorized.owner_did())
.await
.unwrap_or(None);
let verified = room_policy::VerifiedRoomCreation::assemble(room_policy::RoomCreationFacts {
now: chrono::Utc::now(),
actor: room_policy::Actor {
did: authorized.owner_did().to_string(),
role,
member: member.is_some(),
},
room: room_policy::RoomRequest {
room_id: authorized.room_id().to_string(),
visibility,
owner_did: authorized.owner_did().to_string(),
retention_days,
community_owned: false,
},
})?;
let policy = match crate::policy::load_active_compiled(
&state.active_policies_ks,
&state.policies_ks,
crate::policy::model::PolicyPurpose::Rooms,
)
.await
{
Ok(p) => p,
Err(e) => {
tracing::warn!(
error = %e,
room = %authorized.room_id(),
"no active rooms policy; refusing the registration"
);
return Err(vti_common::error::AppError::Forbidden(
"room creation denied (no-policy): this community has no active `rooms` \
policy, so it is not currently deciding whether to host rooms. The shipped \
default installs at boot; check `GET /v1/policies`."
.into(),
));
}
};
room_policy::decide_room_creation(&verified, &policy)
}
pub(crate) async fn handle_create(
doc: TrustTask<CreateRoomBody>,
ctx: RoomCtx,
) -> TrustTaskOutcome {
let state = &ctx.state;
let presenter = ctx.presenter.as_str();
let req = &doc.payload;
let authorized = match authz::authorize_create(&req.room_id, &req.owner_did, presenter) {
Ok(a) => a,
Err(e) => return app_error_to_reject(&doc, &e),
};
if let Err(o) = charged(authorized.room_id(), presenter, RoomTraffic::Operation) {
return o;
}
if let Err(e) = govern_creation(state, &authorized, req.visibility, req.retention_days).await {
return app_error_to_reject(&doc, &e);
}
let room = Room {
room_id: authorized.room_id().to_string(),
owner_did: authorized.owner_did().to_string(),
visibility: req.visibility,
retention_policy: vti_rooms::RetentionPolicy::Chained,
anchor_cadence: req.anchor_cadence.unwrap_or_default(),
epoch: 1,
next_version: 1,
retention_days: req.retention_days.unwrap_or(DEFAULT_RETENTION_DAYS),
epoch_expires_at: Some(now() + EPOCH_LIFETIME_DAYS_SECS),
created_at: now(),
updated_at: now(),
mirror_of: None,
};
if let Err(e) = storage::create_room(&state.rooms_ks, &room).await {
return app_error_to_reject(&doc, &e);
}
if let Err(e) = state.room_storage.assign_new_room(&room.room_id).await {
tracing::warn!(room = %room.room_id, error = %e, "room not yet assigned a storage config");
}
success_response(
&doc,
CreateRoomResponse {
room_id: req.room_id.clone(),
epoch: 1,
anchor_cadence: Some(room.anchor_cadence),
},
)
}
pub(crate) async fn handle_put_record(
doc: TrustTask<PutRecordBody>,
ctx: RoomCtx,
) -> TrustTaskOutcome {
let req = doc.payload.clone();
put_record(&doc, &ctx, req, Vec::new()).await
}
pub(super) async fn put_record<P>(
doc: &TrustTask<P>,
ctx: &RoomCtx,
req: PutRecordBody,
blobs: Vec<String>,
) -> TrustTaskOutcome {
let state = &ctx.state;
let room = match storage::get_room(&state.rooms_ks, &req.room_id).await {
Ok(r) => r,
Err(e) => return app_error_to_reject(doc, &e),
};
let (presenter, verifier) = presenter_and_verifier(state, &ctx.presenter);
let authorized = match authz::authorize(
&room,
&req.presentation,
Action::Write,
&presenter,
now(),
&verifier,
&state.room_revocations_ks,
)
.await
{
Ok(a) => a,
Err(e) => return app_error_to_reject(doc, &e),
};
if let Err(o) = charged(&room.room_id, &presenter, RoomTraffic::Operation) {
return o;
}
if let Some(refusal) =
vti_rooms::blob_tasks::record_epoch_refused(&room, req.sealed.as_ref().map(|s| s.epoch))
{
return super::files::refused(doc, refusal);
}
let references = match state
.room_files
.prepare_record(&room, authorized.member(), &req.key, &blobs)
.await
{
Ok(r) => r,
Err(e) => {
return super::files::refused(doc, vti_rooms::blob_tasks::record_blobs_refused(e));
}
};
let author = room
.visibility
.discloses_actor()
.then(|| authorized.subject().to_string());
let record = Record {
key: req.key.clone(),
version: 0, epoch: req.sealed.as_ref().map(|s| s.epoch),
status: RecordStatus::Active,
pinned: false,
sealed: req.sealed.as_ref().map(|s| s.ciphertext.clone()),
nonce: req.sealed.as_ref().map(|s| s.nonce.clone()),
sealed_version: None,
cleartext: req
.cleartext
.as_ref()
.map(|c| serde_json::to_value(c).unwrap_or(Value::Null)),
author,
updated_at: 0,
};
match storage::put_record(
&state.rooms_ks,
&state.room_records_ks,
&req.room_id,
record,
req.expected_version,
now(),
)
.await
{
Ok(stored) => {
if let Err(e) = references.apply().await {
tracing::error!(error = %e, room = %req.room_id, record = %stored.key,
"record stored but its blob references were not updated");
}
audit_room(
state,
&room,
&authorized,
RoomOperation::PutRecord,
Some(&stored.key),
)
.await;
success_response(
doc,
PutRecordResponse {
key: stored.key,
version: stored.version,
epoch: stored.epoch,
},
)
}
Err(e) => app_error_to_reject(doc, &e),
}
}
pub(crate) async fn handle_get_record(
doc: TrustTask<GetRecordBody>,
ctx: RoomCtx,
) -> TrustTaskOutcome {
let req = doc.payload.clone();
get_record(&doc, &ctx, req, false).await
}
pub(super) async fn get_record<P>(
doc: &TrustTask<P>,
ctx: &RoomCtx,
req: GetRecordBody,
v2: bool,
) -> TrustTaskOutcome {
let state = &ctx.state;
let room = match storage::get_room(&state.rooms_ks, &req.room_id).await {
Ok(r) => r,
Err(e) => return app_error_to_reject(doc, &e),
};
let (presenter, verifier) = presenter_and_verifier(state, &ctx.presenter);
let authorized = match authz::authorize(
&room,
&req.presentation,
Action::Read,
&presenter,
now(),
&verifier,
&state.room_revocations_ks,
)
.await
{
Ok(a) => a,
Err(e) => return app_error_to_reject(doc, &e),
};
if let Err(o) = charged(&room.room_id, &presenter, RoomTraffic::Operation) {
return o;
}
let snapshot = match v2 {
true => Some(state.room_files.read_snapshot().await),
false => None,
};
match storage::get_record(&state.room_records_ks, &req.room_id, &req.key).await {
Ok(record) => {
audit_room(
state,
&room,
&authorized,
RoomOperation::GetRecord,
Some(&req.key),
)
.await;
let (commitment, trace) = record_verification(state, &req.room_id, &req.key).await;
let response = GetRecordResponse::of(&record, commitment.as_ref(), trace);
let Some(snapshot) = snapshot else {
return success_response(doc, response);
};
super::files::render(
doc,
vti_rooms::blob_tasks::get_v2_response(&snapshot, &req.room_id, &req.key, response)
.await,
)
}
Err(e) => app_error_to_reject(doc, &e),
}
}
pub(crate) async fn handle_list_records(
doc: TrustTask<ListRecordsBody>,
ctx: RoomCtx,
) -> TrustTaskOutcome {
let req = doc.payload.clone();
list_records(&doc, &ctx, req, false).await
}
pub(super) async fn list_records<P>(
doc: &TrustTask<P>,
ctx: &RoomCtx,
req: ListRecordsBody,
v2: bool,
) -> TrustTaskOutcome {
let state = &ctx.state;
let room = match storage::get_room(&state.rooms_ks, &req.room_id).await {
Ok(r) => r,
Err(e) => return app_error_to_reject(doc, &e),
};
let (presenter, verifier) = presenter_and_verifier(state, &ctx.presenter);
let authorized = match authz::authorize(
&room,
&req.presentation,
Action::Read,
&presenter,
now(),
&verifier,
&state.room_revocations_ks,
)
.await
{
Ok(a) => a,
Err(e) => return app_error_to_reject(doc, &e),
};
if let Err(o) = charged(&room.room_id, &presenter, RoomTraffic::Operation) {
return o;
}
let snapshot = match v2 {
true => Some(state.room_files.read_snapshot().await),
false => None,
};
let page = match storage::list_records_page(
&state.room_records_ks,
&req.room_id,
req.prefix.as_deref(),
req.since_version,
req.cursor.as_deref(),
req.limit,
)
.await
{
Ok(p) => p,
Err(e) => return app_error_to_reject(doc, &e),
};
audit_room(state, &room, &authorized, RoomOperation::ListRecords, None).await;
let head = room_head(state, &req.room_id).await;
let response = ListRecordsResponse {
records: page.records.iter().map(|r| r.metadata()).collect(),
cursor: page.cursor,
data_commitment: head
.as_ref()
.map(|h| vti_rooms::merkle::to_multibase(&h.root)),
record_count: head.as_ref().map(|h| h.record_count),
head_version: head.as_ref().map(|h| h.head_version),
};
let Some(snapshot) = snapshot else {
return success_response(doc, response);
};
super::files::render(
doc,
vti_rooms::blob_tasks::list_v2_response(&snapshot, &req.room_id, response).await,
)
}
async fn record_verification(
state: &AppState,
room_id: &str,
key: &str,
) -> (
Option<vti_rooms::merkle::TreeHead>,
Option<vti_rooms::merkle::InclusionProof>,
) {
match storage::tree_head_with_trace(&state.room_records_ks, room_id, key).await {
Ok((head, trace)) => (Some(head), trace),
Err(e) => {
tracing::error!(
room = %room_id,
error = %e,
"could not commit the room; answering the read without a commitment or a trace"
);
(None, None)
}
}
}
async fn room_head(state: &AppState, room_id: &str) -> Option<vti_rooms::merkle::TreeHead> {
match storage::tree_head(&state.room_records_ks, room_id).await {
Ok(head) => Some(head),
Err(e) => {
tracing::error!(
room = %room_id,
error = %e,
"could not compute the room's data commitment; answering without one"
);
None
}
}
}
pub(crate) async fn handle_mint_epoch(
doc: TrustTask<MintEpochBody>,
ctx: RoomCtx,
) -> TrustTaskOutcome {
let state = &ctx.state;
let presenter = ctx.presenter.as_str();
let req = &doc.payload;
let room = match storage::get_room(&state.rooms_ks, &req.room_id).await {
Ok(r) => r,
Err(e) => return app_error_to_reject(&doc, &e),
};
let (presenter, verifier) = presenter_and_verifier(state, presenter);
let authorized = match authz::authorize(
&room,
&req.presentation,
Action::Admin,
&presenter,
now(),
&verifier,
&state.room_revocations_ks,
)
.await
{
Ok(a) => a,
Err(e) => return app_error_to_reject(&doc, &e),
};
if let Err(o) = charged(&room.room_id, &presenter, RoomTraffic::Operation) {
return o;
}
if req.link.is_some() && !room.retention_policy.links_epochs() {
return app_error_to_reject(
&doc,
&vti_common::error::AppError::Validation(format!(
"room `{}` does not keep an epoch key chain, so it accepts no epoch link",
req.room_id
)),
);
}
if let Some(link) = &req.link
&& link.epoch != req.epoch
{
return app_error_to_reject(
&doc,
&vti_common::error::AppError::Validation(format!(
"the epoch link is for epoch {} but this mint advances to {}",
link.epoch, req.epoch
)),
);
}
match storage::advance_epoch(&state.rooms_ks, &req.room_id, req.epoch, now()).await {
Ok(updated) => {
if let Some(link) = &req.link
&& let Err(e) =
storage::put_epoch_link(&state.room_epoch_links_ks, &req.room_id, link).await
{
return app_error_to_reject(&doc, &e);
}
if let Some(commit) = &req.commit
&& let Err(e) =
storage::put_commit(&state.room_epoch_links_ks, &req.room_id, req.epoch, commit)
.await
{
return app_error_to_reject(&doc, &e);
}
audit_room(state, &room, &authorized, RoomOperation::MintEpoch, None).await;
success_response(
&doc,
MintEpochResponse {
room_id: updated.room_id,
epoch: updated.epoch,
},
)
}
Err(e) => app_error_to_reject(&doc, &e),
}
}
pub(crate) async fn handle_epoch_prune(
doc: TrustTask<PruneBody>,
ctx: RoomCtx,
) -> TrustTaskOutcome {
let state = &ctx.state;
let presenter = ctx.presenter.as_str();
let req = &doc.payload;
let room = match storage::get_room(&state.rooms_ks, &req.room_id).await {
Ok(r) => r,
Err(e) => return app_error_to_reject(&doc, &e),
};
let (presenter, verifier) = presenter_and_verifier(state, presenter);
let authorized = match authz::authorize(
&room,
&req.presentation,
Action::Admin,
&presenter,
now(),
&verifier,
&state.room_revocations_ks,
)
.await
{
Ok(a) => a,
Err(e) => return app_error_to_reject(&doc, &e),
};
if let Err(o) = charged(&room.room_id, &presenter, RoomTraffic::Operation) {
return o;
}
if req.before_epoch >= room.epoch {
return app_error_to_reject(
&doc,
&vti_common::error::AppError::Validation(format!(
"`beforeEpoch` {} is at or above the current epoch {} of `{}`; pruning there \
would drop the whole chain, not shorten it",
req.before_epoch, room.epoch, req.room_id
)),
);
}
let (pruned, earliest_rung) = match storage::prune_chain(
&state.room_epoch_links_ks,
&req.room_id,
req.before_epoch,
)
.await
{
Ok(r) => r,
Err(e) => return app_error_to_reject(&doc, &e),
};
audit_room(state, &room, &authorized, RoomOperation::MintEpoch, None).await;
success_response(
&doc,
PruneResponse {
room_id: req.room_id.clone(),
pruned: pruned as u32,
earliest_rung,
},
)
}
pub(crate) async fn handle_epoch_commits(
doc: TrustTask<CommitsBody>,
ctx: RoomCtx,
) -> TrustTaskOutcome {
let state = &ctx.state;
let presenter = ctx.presenter.as_str();
let req = &doc.payload;
let room = match storage::get_room(&state.rooms_ks, &req.room_id).await {
Ok(r) => r,
Err(e) => return app_error_to_reject(&doc, &e),
};
let (presenter, verifier) = presenter_and_verifier(state, presenter);
let authorized = match authz::authorize(
&room,
&req.presentation,
Action::Read,
&presenter,
now(),
&verifier,
&state.room_revocations_ks,
)
.await
{
Ok(a) => a,
Err(e) => return app_error_to_reject(&doc, &e),
};
if let Err(o) = charged(&room.room_id, &presenter, RoomTraffic::Operation) {
return o;
}
let commits = match storage::commits_since(
&state.room_epoch_links_ks,
&req.room_id,
req.since_epoch,
req.limit.unwrap_or(100).clamp(1, 100) as usize,
)
.await
{
Ok(c) => c,
Err(e) => return app_error_to_reject(&doc, &e),
};
audit_room(state, &room, &authorized, RoomOperation::ListRecords, None).await;
success_response(
&doc,
CommitsResponse {
room_id: req.room_id.clone(),
commits,
room_epoch: room.epoch,
},
)
}
pub(crate) async fn handle_epoch_chain(
doc: TrustTask<ChainBody>,
ctx: RoomCtx,
) -> TrustTaskOutcome {
let state = &ctx.state;
let presenter = ctx.presenter.as_str();
let req = &doc.payload;
let room = match storage::get_room(&state.rooms_ks, &req.room_id).await {
Ok(r) => r,
Err(e) => return app_error_to_reject(&doc, &e),
};
let (presenter, verifier) = presenter_and_verifier(state, presenter);
let authorized = match authz::authorize(
&room,
&req.presentation,
Action::Read,
&presenter,
now(),
&verifier,
&state.room_revocations_ks,
)
.await
{
Ok(a) => a,
Err(e) => return app_error_to_reject(&doc, &e),
};
if let Err(o) = charged(&room.room_id, &presenter, RoomTraffic::Operation) {
return o;
}
let mut links = match storage::list_epoch_links(&state.room_epoch_links_ks, &req.room_id).await
{
Ok(l) => l,
Err(e) => return app_error_to_reject(&doc, &e),
};
links.reverse();
if let Some(from) = req.from_epoch {
links.retain(|l| l.epoch <= from);
}
if let Some(limit) = req.limit {
links.truncate(limit as usize);
}
audit_room(state, &room, &authorized, RoomOperation::ListRecords, None).await;
success_response(
&doc,
ChainResponse {
room_id: req.room_id.clone(),
links,
},
)
}
pub(crate) async fn handle_transfer_owner(
doc: TrustTask<TransferOwnerBody>,
ctx: RoomCtx,
) -> TrustTaskOutcome {
let state = &ctx.state;
let presenter = ctx.presenter.as_str();
let req = &doc.payload;
let room = match storage::get_room(&state.rooms_ks, &req.room_id).await {
Ok(r) => r,
Err(e) => return app_error_to_reject(&doc, &e),
};
let (presenter, verifier) = presenter_and_verifier(state, presenter);
let operation = RoomOperation::TransferOwner;
let authorized = match authz::authorize(
&room,
&req.presentation,
operation.required_action(),
&presenter,
now(),
&verifier,
&state.room_revocations_ks,
)
.await
{
Ok(a) => a,
Err(e) => return app_error_to_reject(&doc, &e),
};
if let Err(o) = charged(&room.room_id, &presenter, RoomTraffic::Operation) {
return o;
}
match storage::set_owner(&state.rooms_ks, &req.room_id, &req.new_owner_did, now()).await {
Ok(updated) => {
audit_room(state, &room, &authorized, operation, None).await;
success_response(
&doc,
OwnerResponse {
room_id: updated.room_id,
owner_did: updated.owner_did,
},
)
}
Err(e) => app_error_to_reject(&doc, &e),
}
}
pub(crate) async fn handle_claim_owner(
doc: TrustTask<ClaimOwnerBody>,
ctx: RoomCtx,
) -> TrustTaskOutcome {
let state = &ctx.state;
let presenter = ctx.presenter.as_str();
let req = &doc.payload;
let room = match storage::get_room(&state.rooms_ks, &req.room_id).await {
Ok(r) => r,
Err(e) => return app_error_to_reject(&doc, &e),
};
let (presenter, verifier) = presenter_and_verifier(state, presenter);
let keys = DataIntegrityKeys(state.trust_task_vm_resolver());
let nominated =
match nomination::verify(&req.nomination, &room.room_id, &presenter, &keys).await {
Ok(n) => n,
Err(e) => return app_error_to_reject(&doc, &e),
};
let lifecycle = room.lifecycle(now());
if !lifecycle.admits_a_claim() {
return app_error_to_reject(
&doc,
&vti_common::error::AppError::Forbidden(format!(
"room `{}` is {} — a room becomes claimable only once its epoch has \
lapsed and the grace window after it has also passed without a renewal",
room.room_id,
lifecycle.as_str()
)),
);
}
let operation = RoomOperation::ClaimOwner;
let authorized = match authz::authorize(
&room,
&req.presentation,
operation.required_action(),
&presenter,
now(),
&verifier,
&state.room_revocations_ks,
)
.await
{
Ok(a) => a,
Err(e) => return app_error_to_reject(&doc, &e),
};
if let Err(e) = authorized.also_unrevoked(nominated.facts()) {
return app_error_to_reject(&doc, &e);
}
if let Err(o) = charged(&room.room_id, &presenter, RoomTraffic::Operation) {
return o;
}
match storage::set_owner(&state.rooms_ks, &req.room_id, &presenter, now()).await {
Ok(updated) => {
audit_room(state, &room, &authorized, operation, None).await;
success_response(
&doc,
OwnerResponse {
room_id: updated.room_id,
owner_did: updated.owner_did,
},
)
}
Err(e) => app_error_to_reject(&doc, &e),
}
}
pub(crate) async fn handle_curate_record(
doc: TrustTask<CurateRecordBody>,
ctx: RoomCtx,
) -> TrustTaskOutcome {
let state = &ctx.state;
let presenter = ctx.presenter.as_str();
let req = &doc.payload;
if req.status.is_none() && req.pinned.is_none() {
return app_error_to_reject(
&doc,
&vti_common::error::AppError::Validation(
"a curation must change something: supply `status`, `pinned`, or both".into(),
),
);
}
let room = match storage::get_room(&state.rooms_ks, &req.room_id).await {
Ok(r) => r,
Err(e) => return app_error_to_reject(&doc, &e),
};
let (presenter, verifier) = presenter_and_verifier(state, presenter);
let authorized = match authz::authorize(
&room,
&req.presentation,
Action::Curate,
&presenter,
now(),
&verifier,
&state.room_revocations_ks,
)
.await
{
Ok(a) => a,
Err(e) => return app_error_to_reject(&doc, &e),
};
if let Err(o) = charged(&room.room_id, &presenter, RoomTraffic::Operation) {
return o;
}
let release = match state
.room_files
.prepare_release(&req.room_id, &req.key)
.await
{
Ok(r) => r,
Err(e) => return app_error_to_reject(&doc, &e),
};
match storage::curate_record(
&state.rooms_ks,
&state.room_records_ks,
&req.room_id,
&req.key,
storage::Curation {
status: req.status,
pinned: req.pinned,
expected_version: req.expected_version,
},
now(),
)
.await
{
Ok(curated) => {
if curated.status == RecordStatus::Retracted
&& let Err(e) = release.apply().await
{
tracing::error!(error = %e, room = %req.room_id, record = %curated.key,
"record retracted but its blobs were not released");
}
audit_room(
state,
&room,
&authorized,
RoomOperation::CurateRecord,
Some(&curated.key),
)
.await;
success_response(
&doc,
CurateRecordResponse {
key: curated.key,
version: curated.version,
status: curated.status,
pinned: curated.pinned,
},
)
}
Err(e) => app_error_to_reject(&doc, &e),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_support::build_test_vtc;
use serde_json::json;
use vti_rooms::Visibility;
use vti_rooms_dtg::test_support::RoomFixture;
async fn spine_presenter(state: &AppState, doc: &TrustTask<Value>) -> String {
crate::trust_tasks::helpers::verify_trust_task_proof(state, doc)
.await
.expect("a test document is signed by its fixture owner")
}
async fn handle_create(state: &AppState, doc: TrustTask<Value>) -> TrustTaskOutcome {
let presenter = spine_presenter(state, &doc).await;
super::dispatch(state, doc, &presenter).await
}
async fn handle_put_record(state: &AppState, doc: TrustTask<Value>) -> TrustTaskOutcome {
let presenter = spine_presenter(state, &doc).await;
super::dispatch(state, doc, &presenter).await
}
async fn handle_get_record(state: &AppState, doc: TrustTask<Value>) -> TrustTaskOutcome {
let presenter = spine_presenter(state, &doc).await;
super::dispatch(state, doc, &presenter).await
}
async fn handle_list_records(state: &AppState, doc: TrustTask<Value>) -> TrustTaskOutcome {
let presenter = spine_presenter(state, &doc).await;
super::dispatch(state, doc, &presenter).await
}
async fn handle_mint_epoch(state: &AppState, doc: TrustTask<Value>) -> TrustTaskOutcome {
let presenter = spine_presenter(state, &doc).await;
super::dispatch(state, doc, &presenter).await
}
async fn handle_curate_record(state: &AppState, doc: TrustTask<Value>) -> TrustTaskOutcome {
let presenter = spine_presenter(state, &doc).await;
super::dispatch(state, doc, &presenter).await
}
async fn handle_transfer_owner(state: &AppState, doc: TrustTask<Value>) -> TrustTaskOutcome {
let presenter = spine_presenter(state, &doc).await;
super::dispatch(state, doc, &presenter).await
}
async fn handle_claim_owner(state: &AppState, doc: TrustTask<Value>) -> TrustTaskOutcome {
let presenter = spine_presenter(state, &doc).await;
super::dispatch(state, doc, &presenter).await
}
async fn doc(
state: &AppState,
uri: &str,
payload: Value,
signer_did: &str,
signer_key: &str,
) -> TrustTask<Value> {
let recipient = state
.config
.read()
.await
.vtc_did
.clone()
.unwrap_or_else(|| "did:key:zVtc".to_string());
let signed = vta_sdk::trust_task_sign::build_signed(
uri, payload, signer_did, signer_key, &recipient,
)
.await
.expect("sign the request");
serde_json::from_str(&signed).expect("a signed document is a document")
}
fn payload_of(out: &TrustTaskOutcome) -> Value {
let d: Value = serde_json::from_slice(&out.body).expect("response is JSON");
d.get("payload").cloned().unwrap_or(Value::Null)
}
async fn seed_creator(state: &AppState, f: &RoomFixture) {
crate::policy::default::install_defaults(&state.policies_ks, &state.active_policies_ks)
.await
.expect("install the shipped default policies");
crate::members::storage::store_member(
&state.members_ks,
&crate::members::Member::fresh(f.room.owner_did.clone()),
)
.await
.expect("seed the creator's member row");
}
async fn permit_private(state: &AppState) {
let source = "package vtc.rooms\n\n\
import rego.v1\n\n\
default decision := {\"effect\": \"deny\", \"with\": {\"code\": \"not-a-member\"}}\n\n\
decision := {\"effect\": \"allow\"} if input.actor.member == true\n";
let id = uuid::Uuid::new_v4();
let compiled =
crate::policy::engine::compile(source, id).expect("the permissive policy compiles");
let mut policy = crate::policy::storage::new_policy(
crate::policy::model::PolicyPurpose::Rooms,
source.to_string(),
*compiled.source_sha256(),
"did:key:zOperator".to_string(),
2,
);
policy.id = id;
crate::policy::storage::store_policy(&state.policies_ks, &policy)
.await
.expect("store");
crate::policy::storage::set_active_policy_id(
&state.active_policies_ks,
crate::policy::model::PolicyPurpose::Rooms,
id,
)
.await
.expect("activate");
}
async fn create(state: &AppState, f: &RoomFixture) -> TrustTaskOutcome {
seed_creator(state, f).await;
if matches!(f.room.visibility, Visibility::Private) {
permit_private(state).await;
}
handle_create(
state,
doc(
state,
vti_rooms::wire::ROOMS_CREATE_TYPE,
json!({
"roomId": f.room.room_id,
"visibility": f.room.visibility,
"ownerDid": f.room.owner_did,
}),
&f.owner.did,
&f.owner.secret_multibase,
)
.await,
)
.await
}
#[tokio::test]
async fn the_spine_executes_a_redelivered_document_once() {
let tv = build_test_vtc().await;
let state = &tv.state;
let f = RoomFixture::new(Visibility::Open).await;
seed_creator(state, &f).await;
let signed = doc(
state,
vti_rooms::wire::ROOMS_CREATE_TYPE,
json!({
"roomId": f.room.room_id,
"visibility": f.room.visibility,
"ownerDid": f.room.owner_did,
}),
&f.owner.did,
&f.owner.secret_multibase,
)
.await;
let body = serde_json::to_vec(&signed).expect("a document serialises");
let first = crate::trust_tasks::dispatch_trust_task_core(
state,
&crate::trust_tasks::JoinAuthCtx::rest(),
&body,
)
.await;
assert!(
first.status.is_success(),
"the first registration must succeed: {}",
payload_of(&first)
);
let second = crate::trust_tasks::dispatch_trust_task_core(
state,
&crate::trust_tasks::JoinAuthCtx::rest(),
&body,
)
.await;
assert!(
second.status.is_success(),
"a duplicate is never a failure — the task did not fail, it already \
happened: {}",
payload_of(&second)
);
assert_eq!(
payload_of(&second),
payload_of(&first),
"the duplicate must be answered with the original result"
);
}
#[tokio::test]
async fn the_spine_refuses_a_different_document_reusing_an_id() {
let tv = build_test_vtc().await;
let state = &tv.state;
let f = RoomFixture::new(Visibility::Open).await;
let g = RoomFixture::new(Visibility::Open).await;
seed_creator(state, &f).await;
crate::members::storage::store_member(
&state.members_ks,
&crate::members::Member::fresh(g.room.owner_did.clone()),
)
.await
.expect("seed the second creator");
let first = doc(
state,
vti_rooms::wire::ROOMS_CREATE_TYPE,
json!({
"roomId": f.room.room_id,
"visibility": f.room.visibility,
"ownerDid": f.room.owner_did,
}),
&f.owner.did,
&f.owner.secret_multibase,
)
.await;
let out = crate::trust_tasks::dispatch_trust_task_core(
state,
&crate::trust_tasks::JoinAuthCtx::rest(),
&serde_json::to_vec(&first).unwrap(),
)
.await;
assert!(out.status.is_success(), "{}", payload_of(&out));
let mut second = doc(
state,
vti_rooms::wire::ROOMS_CREATE_TYPE,
json!({
"roomId": g.room.room_id,
"visibility": g.room.visibility,
"ownerDid": g.room.owner_did,
}),
&g.owner.did,
&g.owner.secret_multibase,
)
.await;
second.id = first.id.clone();
let out = crate::trust_tasks::dispatch_trust_task_core(
state,
&crate::trust_tasks::JoinAuthCtx::rest(),
&serde_json::to_vec(&second).unwrap(),
)
.await;
assert!(
!out.status.is_success(),
"a different document under a spent id must not be executed: {}",
payload_of(&out)
);
assert!(
vti_rooms::storage::get_room(&state.rooms_ks, &g.room.room_id)
.await
.is_err(),
"and it must leave no room behind"
);
}
#[tokio::test]
async fn a_stranger_cannot_register_a_room_on_this_community() {
let tv = build_test_vtc().await;
let state = &tv.state;
let f = RoomFixture::new(Visibility::Open).await;
crate::policy::default::install_defaults(&state.policies_ks, &state.active_policies_ks)
.await
.expect("install defaults");
let out = handle_create(
state,
doc(
state,
vti_rooms::wire::ROOMS_CREATE_TYPE,
json!({
"roomId": f.room.room_id,
"visibility": f.room.visibility,
"ownerDid": f.room.owner_did,
}),
&f.owner.did,
&f.owner.secret_multibase,
)
.await,
)
.await;
assert!(
!out.status.is_success(),
"a non-member must not register a room here: {}",
payload_of(&out)
);
assert!(
String::from_utf8_lossy(&out.body).contains("not-a-member"),
"the refusal carries the policy's own code: {}",
payload_of(&out)
);
assert!(
vti_rooms::storage::get_room(&state.rooms_ks, &f.room.room_id)
.await
.is_err(),
"a refused registration must not leave the identifier taken"
);
}
#[tokio::test]
async fn a_member_is_refused_a_private_room_until_the_community_enables_it() {
let tv = build_test_vtc().await;
let state = &tv.state;
let f = RoomFixture::new(Visibility::Private).await;
crate::policy::default::install_defaults(&state.policies_ks, &state.active_policies_ks)
.await
.expect("install defaults");
crate::members::storage::store_member(
&state.members_ks,
&crate::members::Member::fresh(f.room.owner_did.clone()),
)
.await
.expect("seed the member row");
let out = handle_create(
state,
doc(
state,
vti_rooms::wire::ROOMS_CREATE_TYPE,
json!({
"roomId": f.room.room_id,
"visibility": f.room.visibility,
"ownerDid": f.room.owner_did,
}),
&f.owner.did,
&f.owner.secret_multibase,
)
.await,
)
.await;
assert!(
String::from_utf8_lossy(&out.body).contains("private-tier-not-enabled"),
"a member is still refused the tier the community has not enabled: {}",
payload_of(&out)
);
permit_private(state).await;
assert!(
create(state, &f).await.status.is_success(),
"once the community permits private rooms, the same request succeeds"
);
}
#[tokio::test]
async fn a_room_cannot_be_registered_in_somebody_elses_name() {
let tv = build_test_vtc().await;
let state = &tv.state;
let f = RoomFixture::new(Visibility::Open).await;
let mallory = vti_rooms_dtg::test_support::Party::new();
let out = handle_create(
state,
doc(
state,
vti_rooms::wire::ROOMS_CREATE_TYPE,
json!({
"roomId": f.room.room_id,
"visibility": f.room.visibility,
"ownerDid": f.room.owner_did,
}),
&mallory.did,
&mallory.secret_multibase,
)
.await,
)
.await;
assert!(
!out.status.is_success(),
"a signer who is not the named owner must be refused: {}",
payload_of(&out)
);
assert!(
vti_rooms::storage::get_room(&state.rooms_ks, &f.room.room_id)
.await
.is_err(),
"a refused registration must not leave the identifier taken"
);
assert!(create(state, &f).await.status.is_success());
}
#[tokio::test]
async fn a_delivered_list_is_enforced_on_every_version_and_served_back() {
let tv = build_test_vtc().await;
let state = &tv.state;
let f = RoomFixture::new(Visibility::Open).await;
assert!(create(state, &f).await.status.is_success());
let list = f
.revocation_list(1, vec![RoomFixture::withdraw_subject(&f.successor.did)])
.await;
let call =
|uri: &'static str, payload: Value, signer: &vti_rooms_dtg::test_support::Party| {
let did = signer.did.clone();
let key = signer.secret_multibase.clone();
async move {
let d = doc(state, uri, payload, &did, &key).await;
let presenter = spine_presenter(state, &d).await;
super::dispatch(state, d, &presenter).await
}
};
let out = call(
vti_rooms::revocation_tasks::PUT_TYPE,
json!({ "roomId": f.room.room_id, "presentation": f.as_owner(), "list": list }),
&f.owner,
)
.await;
assert!(out.status.is_success(), "put: {}", payload_of(&out));
assert_eq!(payload_of(&out)["head"]["sequence"], 1);
assert!(super::serves(vti_rooms::revocation_tasks::PUT_TYPE));
let out = call(
vti_rooms::wire::ROOMS_RECORDS_GET_TYPE,
json!({ "roomId": f.room.room_id, "key": "k1", "presentation": f.as_successor() }),
&f.successor,
)
.await;
assert_eq!(out.status.as_u16(), 403, "{}", payload_of(&out));
assert_eq!(payload_of(&out)["code"], "permissionDenied");
let out = call(
vti_rooms::revocation_tasks::GET_TYPE,
json!({ "roomId": f.room.room_id, "presentation": f.as_owner() }),
&f.owner,
)
.await;
assert!(out.status.is_success(), "get: {}", payload_of(&out));
assert_eq!(payload_of(&out)["list"], list, "served back unaltered");
}
#[tokio::test]
async fn a_room_is_created_and_a_record_round_trips() {
let tv = build_test_vtc().await;
let state = &tv.state;
let f = RoomFixture::new(Visibility::Open).await;
let out = create(state, &f).await;
assert!(out.status.is_success(), "create: {}", payload_of(&out));
let out = handle_put_record(
state,
doc(
state,
vti_rooms::wire::ROOMS_RECORDS_PUT_TYPE,
json!({
"roomId": f.room.room_id, "key": "k1", "presentation": f.as_owner(),
"cleartext": { "body": "a decision" }
}),
&f.owner.did,
&f.owner.secret_multibase,
)
.await,
)
.await;
assert!(out.status.is_success(), "put: {}", payload_of(&out));
assert_eq!(payload_of(&out)["version"], 1);
let out = handle_get_record(
state,
doc(
state,
vti_rooms::wire::ROOMS_RECORDS_GET_TYPE,
json!({ "roomId": f.room.room_id, "key": "k1", "presentation": f.as_owner() }),
&f.owner.did,
&f.owner.secret_multibase,
)
.await,
)
.await;
assert!(out.status.is_success());
let got = payload_of(&out);
assert_eq!(got["cleartext"]["body"], "a decision");
assert_eq!(
got["author"], f.owner.did,
"the author is the subject the chain established, not the room's owner field"
);
}
#[tokio::test]
async fn a_record_sealed_under_a_superseded_epoch_is_epoch_mismatch() {
let tv = build_test_vtc().await;
let state = &tv.state;
let f = RoomFixture::new(Visibility::Attributed).await;
let out = create(state, &f).await;
assert!(out.status.is_success(), "create: {}", payload_of(&out));
let current = storage::get_room(&state.rooms_ks, &f.room.room_id)
.await
.unwrap()
.epoch;
let out = handle_put_record(
state,
doc(
state,
vti_rooms::wire::ROOMS_RECORDS_PUT_TYPE,
json!({
"roomId": f.room.room_id, "key": "k1", "presentation": f.as_owner(),
"sealed": {
"ciphertext": "9jK2_QhV1sVvR0m5xAqZ7A",
"nonce": "b0Zt8Qm2Yq1sVvR0",
"epoch": current + 1
}
}),
&f.owner.did,
&f.owner.secret_multibase,
)
.await,
)
.await;
assert!(!out.status.is_success(), "{}", payload_of(&out));
assert_eq!(
payload_of(&out)["code"],
"rooms/records/put:epochMismatch",
"{}",
payload_of(&out)
);
}
#[tokio::test]
async fn an_operation_with_no_authority_chain_is_refused() {
let tv = build_test_vtc().await;
let state = &tv.state;
let f = RoomFixture::new(Visibility::Open).await;
create(state, &f).await;
let out = handle_get_record(
state,
doc(
state,
vti_rooms::wire::ROOMS_RECORDS_GET_TYPE,
json!({
"roomId": f.room.room_id, "key": "k1",
"presentation": { "membership": f.membership, "authority": [] }
}),
&f.owner.did,
&f.owner.secret_multibase,
)
.await,
)
.await;
assert!(!out.status.is_success());
}
#[tokio::test]
async fn an_agent_reads_under_a_narrower_chain_and_cannot_write() {
let tv = build_test_vtc().await;
let state = &tv.state;
let f = RoomFixture::new(Visibility::Open).await;
create(state, &f).await;
handle_put_record(
state,
doc(
state,
vti_rooms::wire::ROOMS_RECORDS_PUT_TYPE,
json!({
"roomId": f.room.room_id, "key": "k", "presentation": f.as_owner(),
"cleartext": { "body": "for the agent" }
}),
&f.owner.did,
&f.owner.secret_multibase,
)
.await,
)
.await;
let out = handle_get_record(
state,
doc(
state,
vti_rooms::wire::ROOMS_RECORDS_GET_TYPE,
json!({ "roomId": f.room.room_id, "key": "k", "presentation": f.as_agent() }),
&f.agent.did,
&f.agent.secret_multibase,
)
.await,
)
.await;
assert!(
out.status.is_success(),
"the agent reads: {}",
payload_of(&out)
);
let out = handle_put_record(
state,
doc(
state,
vti_rooms::wire::ROOMS_RECORDS_PUT_TYPE,
json!({
"roomId": f.room.room_id, "key": "k2", "presentation": f.as_agent(),
"cleartext": { "body": "but must not write" }
}),
&f.agent.did,
&f.agent.secret_multibase,
)
.await,
)
.await;
assert!(!out.status.is_success(), "a read-only chain must not write");
}
#[tokio::test]
async fn a_private_room_is_refused_for_want_of_a_zk_profile() {
let tv = build_test_vtc().await;
let state = &tv.state;
let f = RoomFixture::new(Visibility::Private).await;
create(state, &f).await;
let mut p = f.as_owner();
p.subject_binding = Some("a-binding-nobody-can-check".into());
let out = handle_get_record(
state,
doc(
state,
vti_rooms::wire::ROOMS_RECORDS_GET_TYPE,
json!({ "roomId": f.room.room_id, "key": "k", "presentation": p }),
&f.owner.did,
&f.owner.secret_multibase,
)
.await,
)
.await;
assert!(!out.status.is_success());
assert!(
payload_of(&out)["message"]
.as_str()
.unwrap_or_default()
.contains("zero-knowledge profile"),
"{}",
payload_of(&out)
);
}
#[tokio::test]
async fn a_private_room_refuses_a_presentation_without_a_subject_binding() {
let tv = build_test_vtc().await;
let state = &tv.state;
let f = RoomFixture::new(Visibility::Private).await;
create(state, &f).await;
let out = handle_get_record(
state,
doc(
state,
vti_rooms::wire::ROOMS_RECORDS_GET_TYPE,
json!({ "roomId": f.room.room_id, "key": "k", "presentation": f.as_owner() }),
&f.owner.did,
&f.owner.secret_multibase,
)
.await,
)
.await;
assert!(!out.status.is_success());
assert!(
payload_of(&out)["message"]
.as_str()
.unwrap_or_default()
.contains("subject binding"),
"{}",
payload_of(&out)
);
}
#[tokio::test]
async fn room_operations_are_audited() {
let tv = crate::test_support::TestVtc::builder()
.with_audit(true)
.build()
.await;
let state = &tv.state;
let f = RoomFixture::new(Visibility::Open).await;
create(state, &f).await;
handle_put_record(
state,
doc(
state,
vti_rooms::wire::ROOMS_RECORDS_PUT_TYPE,
json!({
"roomId": f.room.room_id, "key": "k", "presentation": f.as_owner(),
"cleartext": { "body": "x" }
}),
&f.owner.did,
&f.owner.secret_multibase,
)
.await,
)
.await;
handle_get_record(
state,
doc(
state,
vti_rooms::wire::ROOMS_RECORDS_GET_TYPE,
json!({ "roomId": f.room.room_id, "key": "k", "presentation": f.as_owner() }),
&f.owner.did,
&f.owner.secret_multibase,
)
.await,
)
.await;
let rows = audit_rows(state).await;
assert!(
rows.contains("room.records.put") && rows.contains("room.records.get"),
"both the write and the read must be recorded: {rows}"
);
assert!(
rows.contains(&f.owner.did),
"an open room discloses the actor: {rows}"
);
}
#[tokio::test]
async fn a_private_rooms_audit_trail_never_names_the_actor() {
let tv = crate::test_support::TestVtc::builder()
.with_audit(true)
.build()
.await;
let state = &tv.state;
let f = RoomFixture::new(Visibility::Private).await;
create(state, &f).await;
let room = storage::get_room(&state.rooms_ks, &f.room.room_id)
.await
.expect("room");
let mut presentation = f.as_owner();
presentation.subject_binding = Some("a-binding".into());
let authorized = authz::authorize(
&room,
&presentation,
Action::Read,
&f.owner.did,
now(),
&AlwaysVouches,
&state.room_revocations_ks,
)
.await
.expect("authorized");
audit_room(
state,
&room,
&authorized,
RoomOperation::GetRecord,
Some("opaque-key"),
)
.await;
let rows = audit_rows(state).await;
assert!(
rows.contains("room.records.get"),
"the operation is recorded: {rows}"
);
assert!(
rows.contains("opaque-key"),
"the record is recorded — an opaque key identifies without describing: {rows}"
);
assert!(
!rows.contains(&f.owner.did),
"but the actor must appear nowhere in the audit trail: {rows}"
);
}
struct AlwaysVouches;
#[async_trait::async_trait]
impl vti_rooms::authz::ChainVerifier for AlwaysVouches {
async fn verify(
&self,
_: &vti_rooms::Room,
_: &vti_rooms::wire::AuthorityPresentation,
_: Action,
presenter: &str,
) -> Result<vti_rooms::authz::VerifiedChain, vti_common::error::AppError> {
Ok(vti_rooms::authz::VerifiedChain {
subject: presenter.to_string(),
actions: vec!["read".into()],
member: Some(presenter.to_string()),
presented: Default::default(),
})
}
}
async fn audit_rows(state: &AppState) -> String {
let raw = state
.audit_ks
.prefix_iter_raw(Vec::<u8>::new())
.await
.expect("read the audit keyspace");
raw.into_iter()
.map(|(_, v)| String::from_utf8_lossy(&v).into_owned())
.collect::<Vec<_>>()
.join("\n")
}
#[tokio::test]
async fn curating_needs_its_own_grant_and_is_not_implied_by_write() {
let tv = build_test_vtc().await;
let state = &tv.state;
let f = RoomFixture::new(Visibility::Open).await;
create(state, &f).await;
handle_put_record(
state,
doc(
state,
vti_rooms::wire::ROOMS_RECORDS_PUT_TYPE,
json!({
"roomId": f.room.room_id, "key": "k", "presentation": f.as_owner(),
"cleartext": { "body": "a decision" }
}),
&f.owner.did,
&f.owner.secret_multibase,
)
.await,
)
.await;
let out = handle_curate_record(
state,
doc(
state,
vti_rooms::wire::ROOMS_RECORDS_CURATE_TYPE,
json!({
"roomId": f.room.room_id, "key": "k",
"presentation": f.as_agent(), "status": "deprecated"
}),
&f.agent.did,
&f.agent.secret_multibase,
)
.await,
)
.await;
assert!(
!out.status.is_success(),
"a read-only agent must not curate: {}",
payload_of(&out)
);
let out = handle_curate_record(
state,
doc(
state,
vti_rooms::wire::ROOMS_RECORDS_CURATE_TYPE,
json!({
"roomId": f.room.room_id, "key": "k",
"presentation": f.as_owner(), "status": "deprecated", "pinned": true
}),
&f.owner.did,
&f.owner.secret_multibase,
)
.await,
)
.await;
assert!(out.status.is_success(), "{}", payload_of(&out));
let body = payload_of(&out);
assert_eq!(body["status"], "deprecated");
assert_eq!(body["pinned"], true, "pinned and deprecated at once");
assert_eq!(body["version"], 2, "curation assigns a new version");
}
#[tokio::test]
async fn a_retraction_leaves_a_tombstone_a_listing_still_reports() {
let tv = build_test_vtc().await;
let state = &tv.state;
let f = RoomFixture::new(Visibility::Open).await;
create(state, &f).await;
handle_put_record(
state,
doc(
state,
vti_rooms::wire::ROOMS_RECORDS_PUT_TYPE,
json!({
"roomId": f.room.room_id, "key": "k", "presentation": f.as_owner(),
"cleartext": { "body": "secret-body-text" }
}),
&f.owner.did,
&f.owner.secret_multibase,
)
.await,
)
.await;
handle_curate_record(
state,
doc(
state,
vti_rooms::wire::ROOMS_RECORDS_CURATE_TYPE,
json!({
"roomId": f.room.room_id, "key": "k",
"presentation": f.as_owner(), "status": "retracted"
}),
&f.owner.did,
&f.owner.secret_multibase,
)
.await,
)
.await;
let out = handle_list_records(
state,
doc(
state,
vti_rooms::wire::ROOMS_RECORDS_LIST_TYPE,
json!({ "roomId": f.room.room_id, "presentation": f.as_owner() }),
&f.owner.did,
&f.owner.secret_multibase,
)
.await,
)
.await;
let text = payload_of(&out).to_string();
assert!(
text.contains("retracted"),
"the tombstone must still be listed, or sync cannot converge: {text}"
);
let out = handle_get_record(
state,
doc(
state,
vti_rooms::wire::ROOMS_RECORDS_GET_TYPE,
json!({ "roomId": f.room.room_id, "key": "k", "presentation": f.as_owner() }),
&f.owner.did,
&f.owner.secret_multibase,
)
.await,
)
.await;
assert!(
!payload_of(&out).to_string().contains("secret-body-text"),
"but the body is gone: {}",
payload_of(&out)
);
}
#[tokio::test]
async fn listing_returns_metadata_and_never_bodies() {
let tv = build_test_vtc().await;
let state = &tv.state;
let f = RoomFixture::new(Visibility::Open).await;
create(state, &f).await;
handle_put_record(
state,
doc(
state,
vti_rooms::wire::ROOMS_RECORDS_PUT_TYPE,
json!({
"roomId": f.room.room_id, "key": "a", "presentation": f.as_owner(),
"cleartext": { "body": "secret-body-text" }
}),
&f.owner.did,
&f.owner.secret_multibase,
)
.await,
)
.await;
let out = handle_list_records(
state,
doc(
state,
vti_rooms::wire::ROOMS_RECORDS_LIST_TYPE,
json!({ "roomId": f.room.room_id, "presentation": f.as_owner() }),
&f.owner.did,
&f.owner.secret_multibase,
)
.await,
)
.await;
assert!(out.status.is_success());
let text = payload_of(&out).to_string();
assert!(text.contains("\"key\""));
assert!(
!text.contains("secret-body-text"),
"a listing must never carry bodies: {text}"
);
}
#[tokio::test]
async fn minting_an_epoch_requires_admin_and_advances_by_one() {
let tv = build_test_vtc().await;
let state = &tv.state;
let f = RoomFixture::new(Visibility::Open).await;
create(state, &f).await;
let out = handle_mint_epoch(
state,
doc(
state,
vti_rooms::wire::ROOMS_EPOCH_MINT_TYPE,
json!({ "roomId": f.room.room_id, "epoch": 2, "presentation": f.as_agent() }),
&f.agent.did,
&f.agent.secret_multibase,
)
.await,
)
.await;
assert!(!out.status.is_success(), "read may not mint an epoch");
let out = handle_mint_epoch(
state,
doc(
state,
vti_rooms::wire::ROOMS_EPOCH_MINT_TYPE,
json!({ "roomId": f.room.room_id, "epoch": 2, "presentation": f.as_owner() }),
&f.owner.did,
&f.owner.secret_multibase,
)
.await,
)
.await;
assert!(out.status.is_success(), "{}", payload_of(&out));
assert_eq!(payload_of(&out)["epoch"], 2);
let out = handle_mint_epoch(
state,
doc(
state,
vti_rooms::wire::ROOMS_EPOCH_MINT_TYPE,
json!({ "roomId": f.room.room_id, "epoch": 5, "presentation": f.as_owner() }),
&f.owner.did,
&f.owner.secret_multibase,
)
.await,
)
.await;
assert!(!out.status.is_success(), "an epoch may not skip");
}
async fn create_expiring(state: &AppState, f: &RoomFixture, epoch_expires_at: Option<u64>) {
storage::create_room(
&state.rooms_ks,
&Room {
epoch_expires_at,
..f.room.clone()
},
)
.await
.expect("register the room");
}
fn days_ago(n: u64) -> Option<u64> {
Some(now() - n * 24 * 60 * 60)
}
async fn claim(state: &AppState, f: &RoomFixture, payload: Value) -> TrustTaskOutcome {
handle_claim_owner(
state,
doc(
state,
vti_rooms::wire::ROOMS_OWNER_CLAIM_TYPE,
payload,
&f.successor.did,
&f.successor.secret_multibase,
)
.await,
)
.await
}
#[tokio::test]
async fn transferring_requires_admin_and_names_the_new_owner() {
let tv = build_test_vtc().await;
let state = &tv.state;
let f = RoomFixture::new(Visibility::Open).await;
create(state, &f).await;
let out = handle_transfer_owner(
state,
doc(
state,
vti_rooms::wire::ROOMS_OWNER_TRANSFER_TYPE,
json!({
"roomId": f.room.room_id,
"newOwnerDid": f.agent.did,
"presentation": f.as_agent(),
}),
&f.agent.did,
&f.agent.secret_multibase,
)
.await,
)
.await;
assert!(
!out.status.is_success(),
"an agent may not hand away the room"
);
let out = handle_transfer_owner(
state,
doc(
state,
vti_rooms::wire::ROOMS_OWNER_TRANSFER_TYPE,
json!({
"roomId": f.room.room_id,
"newOwnerDid": f.successor.did,
"presentation": f.as_owner(),
"reason": "stepping back",
}),
&f.owner.did,
&f.owner.secret_multibase,
)
.await,
)
.await;
assert!(out.status.is_success(), "{}", payload_of(&out));
assert_eq!(payload_of(&out)["ownerDid"], f.successor.did);
let stored = storage::get_room(&state.rooms_ks, &f.room.room_id)
.await
.unwrap();
assert_eq!(stored.owner_did, f.successor.did, "and it persisted");
}
#[tokio::test]
async fn a_nominated_successor_claims_a_dormant_room() {
let tv = build_test_vtc().await;
let state = &tv.state;
let f = RoomFixture::new(Visibility::Open).await;
create_expiring(state, &f, days_ago(60)).await;
let out = claim(
state,
&f,
json!({
"roomId": f.room.room_id,
"nomination": f.nominate(&f.successor.did, 24).await,
"presentation": f.as_successor(),
"reason": "unreachable since March",
}),
)
.await;
assert!(out.status.is_success(), "{}", payload_of(&out));
assert_eq!(payload_of(&out)["ownerDid"], f.successor.did);
let stored = storage::get_room(&state.rooms_ks, &f.room.room_id)
.await
.unwrap();
assert!(!stored.lifecycle(now()).accepts_writes());
}
#[tokio::test]
async fn renewing_defeats_a_pending_claim() {
let tv = build_test_vtc().await;
let state = &tv.state;
let f = RoomFixture::new(Visibility::Open).await;
create_expiring(state, &f, days_ago(60)).await;
let nomination = f.nominate(&f.successor.did, 24).await;
let out = handle_mint_epoch(
state,
doc(
state,
vti_rooms::wire::ROOMS_EPOCH_MINT_TYPE,
json!({ "roomId": f.room.room_id, "epoch": 2, "presentation": f.as_owner() }),
&f.owner.did,
&f.owner.secret_multibase,
)
.await,
)
.await;
assert!(out.status.is_success(), "{}", payload_of(&out));
let out = claim(
state,
&f,
json!({
"roomId": f.room.room_id,
"nomination": nomination,
"presentation": f.as_successor(),
}),
)
.await;
assert!(
!out.status.is_success(),
"the same nomination that would have worked a moment ago: {}",
payload_of(&out)
);
let stored = storage::get_room(&state.rooms_ks, &f.room.room_id)
.await
.unwrap();
assert_eq!(stored.owner_did, f.owner.did, "and the owner kept the room");
}
#[tokio::test]
async fn a_member_of_a_dormant_room_cannot_claim_without_being_named() {
let tv = build_test_vtc().await;
let state = &tv.state;
let f = RoomFixture::new(Visibility::Open).await;
create_expiring(state, &f, days_ago(60)).await;
let out = claim(
state,
&f,
json!({
"roomId": f.room.room_id,
"nomination": f.nominate(&f.agent.did, 24).await,
"presentation": f.as_successor(),
}),
)
.await;
assert!(
!out.status.is_success(),
"a nomination is bound to the party it names: {}",
payload_of(&out)
);
}
#[tokio::test]
async fn a_bad_nomination_is_reported_before_the_room_is_still_live() {
let tv = build_test_vtc().await;
let state = &tv.state;
let f = RoomFixture::new(Visibility::Open).await;
create_expiring(state, &f, Some(now() + 30 * 24 * 60 * 60)).await;
let out = claim(
state,
&f,
json!({
"roomId": f.room.room_id,
"nomination": f.nominate(&f.agent.did, 24).await,
"presentation": f.as_successor(),
}),
)
.await;
assert!(!out.status.is_success());
let body = payload_of(&out).to_string();
assert!(
!body.contains("dormant"),
"the nomination was never valid; saying `still live` hides that: {body}"
);
}
async fn send(
state: &AppState,
uri: &str,
payload: Value,
f: &RoomFixture,
) -> TrustTaskOutcome {
let d = doc(state, uri, payload, &f.owner.did, &f.owner.secret_multibase).await;
let presenter = spine_presenter(state, &d).await;
super::dispatch(state, d, &presenter).await
}
fn blob(len: usize, seed: u8) -> (Vec<Vec<u8>>, vti_rooms::files::BlobManifest) {
const CHUNK: usize = 16_384;
let bytes: Vec<u8> = (0..len)
.map(|i| (i as u8).wrapping_mul(31).wrapping_add(seed))
.collect();
let mut builder = vti_rooms::files::ManifestBuilder::new(CHUNK as u64);
let chunks: Vec<Vec<u8>> = bytes.chunks(CHUNK).map(<[u8]>::to_vec).collect();
for c in &chunks {
builder.push(c);
}
(chunks, builder.finish().expect("a manifest"))
}
async fn upload(state: &AppState, f: &RoomFixture, seed: u8) -> String {
use base64::Engine as _;
let (chunks, manifest) = blob(40_000, seed);
let out = send(
state,
vti_rooms::wire::ROOMS_BLOBS_UPLOAD_BEGIN_TYPE,
json!({ "roomId": f.room.room_id, "presentation": f.as_owner(), "manifest": manifest }),
f,
)
.await;
assert!(out.status.is_success(), "begin: {}", payload_of(&out));
let begun = payload_of(&out);
let upload_id = begun["uploadId"].as_str().unwrap().to_string();
assert_eq!(begun["missing"].as_array().unwrap().len(), chunks.len());
for (i, c) in chunks.iter().enumerate() {
let out = send(
state,
vti_rooms::wire::ROOMS_BLOBS_UPLOAD_CHUNK_TYPE,
json!({
"uploadId": upload_id, "index": i,
"digestMultibase": vti_rooms::files::digest_multibase(c),
"data": base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(c),
}),
f,
)
.await;
assert!(out.status.is_success(), "chunk {i}: {}", payload_of(&out));
}
let out = send(
state,
vti_rooms::wire::ROOMS_BLOBS_UPLOAD_COMMIT_TYPE,
json!({ "uploadId": upload_id }),
f,
)
.await;
assert!(out.status.is_success(), "commit: {}", payload_of(&out));
let blob_ref = payload_of(&out)["blobRef"].as_str().unwrap().to_string();
assert_eq!(blob_ref, manifest.blob_ref().unwrap());
blob_ref
}
async fn room_files(state: &AppState, f: &RoomFixture) -> u64 {
let out = send(
state,
vti_rooms::wire::ROOMS_INFO_TYPE,
json!({ "roomId": f.room.room_id, "presentation": f.as_owner() }),
f,
)
.await;
assert!(out.status.is_success(), "info: {}", payload_of(&out));
payload_of(&out)["usage"]["room"]["files"].as_u64().unwrap()
}
async fn put_v2(
state: &AppState,
f: &RoomFixture,
key: &str,
blobs: &[&str],
) -> TrustTaskOutcome {
send(
state,
vti_rooms::wire::ROOMS_RECORDS_PUT_V2_TYPE,
json!({
"roomId": f.room.room_id, "key": key, "presentation": f.as_owner(),
"cleartext": { "body": "a file" }, "blobs": blobs,
}),
f,
)
.await
}
#[tokio::test]
async fn a_file_is_uploaded_named_read_back_and_counted() {
use base64::Engine as _;
let tv = build_test_vtc().await;
let state = &tv.state;
let f = RoomFixture::new(Visibility::Open).await;
assert!(create(state, &f).await.status.is_success());
let blob_ref = upload(state, &f, 1).await;
let out = put_v2(state, &f, "doc", &[&blob_ref]).await;
assert!(out.status.is_success(), "put 0.2: {}", payload_of(&out));
let out = send(
state,
vti_rooms::wire::ROOMS_RECORDS_GET_V2_TYPE,
json!({ "roomId": f.room.room_id, "key": "doc", "presentation": f.as_owner() }),
&f,
)
.await;
assert!(out.status.is_success(), "get 0.2: {}", payload_of(&out));
assert_eq!(payload_of(&out)["blobs"], json!([blob_ref]));
let out = send(
state,
vti_rooms::wire::ROOMS_RECORDS_LIST_V2_TYPE,
json!({ "roomId": f.room.room_id, "presentation": f.as_owner() }),
&f,
)
.await;
assert!(out.status.is_success(), "list 0.2: {}", payload_of(&out));
assert_eq!(payload_of(&out)["records"][0]["blobs"], json!([blob_ref]));
let out = send(
state,
vti_rooms::wire::ROOMS_BLOBS_GET_TYPE,
json!({ "roomId": f.room.room_id, "blobRef": blob_ref, "presentation": f.as_owner() }),
&f,
)
.await;
assert!(out.status.is_success(), "blob get: {}", payload_of(&out));
let download_id = payload_of(&out)["downloadId"].as_str().unwrap().to_string();
let (chunks, _) = blob(40_000, 1);
for (i, expected) in chunks.iter().enumerate() {
let out = send(
state,
vti_rooms::wire::ROOMS_BLOBS_CHUNK_TYPE,
json!({ "downloadId": download_id, "index": i }),
&f,
)
.await;
assert!(
out.status.is_success(),
"download {i}: {}",
payload_of(&out)
);
let data = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(payload_of(&out)["data"].as_str().unwrap())
.unwrap();
assert_eq!(&data, expected);
}
assert_eq!(room_files(state, &f).await, 1);
}
#[tokio::test]
async fn a_record_naming_an_uncommitted_blob_is_refused_with_the_declared_code() {
let tv = build_test_vtc().await;
let state = &tv.state;
let f = RoomFixture::new(Visibility::Open).await;
assert!(create(state, &f).await.status.is_success());
let (_, never_uploaded) = blob(20_000, 9);
let out = put_v2(state, &f, "doc", &[&never_uploaded.blob_ref().unwrap()]).await;
assert!(!out.status.is_success());
assert_eq!(
payload_of(&out)["code"],
"rooms/records/put:blobNotFound",
"{}",
payload_of(&out)
);
}
#[tokio::test]
async fn retracting_or_rewriting_a_record_releases_its_file() {
let tv = build_test_vtc().await;
let state = &tv.state;
let f = RoomFixture::new(Visibility::Open).await;
assert!(create(state, &f).await.status.is_success());
let first = upload(state, &f, 1).await;
assert!(put_v2(state, &f, "a", &[&first]).await.status.is_success());
let second = upload(state, &f, 2).await;
assert!(put_v2(state, &f, "b", &[&second]).await.status.is_success());
assert_eq!(room_files(state, &f).await, 2);
let out = send(
state,
vti_rooms::wire::ROOMS_RECORDS_CURATE_TYPE,
json!({
"roomId": f.room.room_id, "key": "a", "presentation": f.as_owner(),
"status": "retracted",
}),
&f,
)
.await;
assert!(out.status.is_success(), "retract: {}", payload_of(&out));
assert_eq!(
room_files(state, &f).await,
1,
"the retraction released its file"
);
let out = send(
state,
vti_rooms::wire::ROOMS_RECORDS_PUT_TYPE,
json!({
"roomId": f.room.room_id, "key": "b", "presentation": f.as_owner(),
"cleartext": { "body": "no file any more" },
}),
&f,
)
.await;
assert!(out.status.is_success(), "0.1 rewrite: {}", payload_of(&out));
assert_eq!(
room_files(state, &f).await,
0,
"a 0.1 rewrite released its file"
);
}
#[tokio::test]
async fn only_the_uploader_can_send_chunks_to_an_upload() {
use base64::Engine as _;
let tv = build_test_vtc().await;
let state = &tv.state;
let f = RoomFixture::new(Visibility::Open).await;
assert!(create(state, &f).await.status.is_success());
let (chunks, manifest) = blob(20_000, 3);
let out = send(
state,
vti_rooms::wire::ROOMS_BLOBS_UPLOAD_BEGIN_TYPE,
json!({ "roomId": f.room.room_id, "presentation": f.as_owner(), "manifest": manifest }),
&f,
)
.await;
let upload_id = payload_of(&out)["uploadId"].as_str().unwrap().to_string();
let d = doc(
state,
vti_rooms::wire::ROOMS_BLOBS_UPLOAD_CHUNK_TYPE,
json!({
"uploadId": upload_id, "index": 0,
"digestMultibase": vti_rooms::files::digest_multibase(&chunks[0]),
"data": base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&chunks[0]),
}),
&f.agent.did,
&f.agent.secret_multibase,
)
.await;
let presenter = spine_presenter(state, &d).await;
let out = super::dispatch(state, d, &presenter).await;
assert_eq!(
payload_of(&out)["code"],
"rooms/blobs/upload/chunk:notFound",
"{}",
payload_of(&out)
);
}
}