use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_json::Value as JsonValue;
use crate::policy::engine::{CompiledPolicy, evaluate};
use vti_common::error::AppError;
use vti_rooms::Visibility;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RoomCreationFacts {
pub now: DateTime<Utc>,
pub actor: Actor,
pub room: RoomRequest,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Actor {
pub did: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub role: Option<String>,
pub member: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RoomRequest {
pub room_id: String,
pub visibility: Visibility,
pub owner_did: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub retention_days: Option<u32>,
#[serde(default)]
pub community_owned: bool,
}
#[derive(Debug, Clone)]
pub struct VerifiedRoomCreation(RoomCreationFacts);
impl VerifiedRoomCreation {
pub fn assemble(facts: RoomCreationFacts) -> Result<Self, AppError> {
if facts.actor.did.trim().is_empty() {
return Err(AppError::Forbidden(
"a room registration must name the party that signed it".into(),
));
}
Ok(Self(facts))
}
pub fn to_input(&self) -> Result<JsonValue, AppError> {
serde_json::to_value(&self.0).map_err(AppError::from)
}
pub fn facts(&self) -> &RoomCreationFacts {
&self.0
}
}
#[derive(Debug, Clone, Deserialize)]
struct Decision {
effect: String,
#[serde(default)]
with: DecisionWith,
}
#[derive(Debug, Clone, Default, Deserialize)]
struct DecisionWith {
#[serde(default)]
code: Option<String>,
#[serde(default)]
reason: Option<String>,
}
const DECISION_QUERY: &str = "data.vtc.rooms.decision";
pub fn decide_room_creation(
verified: &VerifiedRoomCreation,
policy: &CompiledPolicy,
) -> Result<(), AppError> {
let results = evaluate(policy, DECISION_QUERY, verified.to_input()?)?;
let value = results
.get("result")
.and_then(|r| r.as_array())
.and_then(|rows| rows.first())
.and_then(|row| row.get("expressions"))
.and_then(|e| e.as_array())
.and_then(|exprs| exprs.first())
.and_then(|expr| expr.get("value"));
let Some(value) = value else {
tracing::warn!(
room = %verified.facts().room.room_id,
"the active rooms policy produced no decision; refusing the registration"
);
return Err(AppError::Forbidden(
"room creation denied (no-decision): the active rooms policy answered nothing, \
which is refused rather than read as consent"
.into(),
));
};
let decision: Decision = serde_json::from_value(value.clone()).map_err(|e| {
AppError::Internal(format!(
"the active rooms policy returned a decision this service cannot read: {e}"
))
})?;
match decision.effect.as_str() {
"allow" => Ok(()),
"deny" => {
let code = decision.with.code.unwrap_or_else(|| "denied".to_string());
let reason = decision
.with
.reason
.map(|r| format!(": {r}"))
.unwrap_or_default();
Err(AppError::Forbidden(format!(
"room creation denied ({code}){reason}"
)))
}
other => {
tracing::warn!(
effect = other,
room = %verified.facts().room.room_id,
"the active rooms policy returned a verdict room creation cannot honour"
);
Err(AppError::Forbidden(format!(
"room creation denied (unsupported-verdict): the active rooms policy \
answered `{other}`, which this synchronous surface cannot carry out"
)))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::policy::default::default_source;
use crate::policy::engine::compile;
use crate::policy::model::PolicyPurpose;
fn facts(role: Option<&str>, member: bool, visibility: Visibility) -> VerifiedRoomCreation {
VerifiedRoomCreation::assemble(RoomCreationFacts {
now: "2026-09-07T12:00:00Z".parse().unwrap(),
actor: Actor {
did: "did:key:zCreator".into(),
role: role.map(str::to_string),
member,
},
room: RoomRequest {
room_id: "did:webvh:room.example".into(),
visibility,
owner_did: "did:key:zCreator".into(),
retention_days: Some(90),
community_owned: false,
},
})
.expect("a signed registration assembles")
}
fn default_policy() -> CompiledPolicy {
compile(default_source(PolicyPurpose::Rooms), uuid::Uuid::nil())
.expect("the bundled rooms policy compiles")
}
#[test]
fn a_member_may_create_an_open_room() {
decide_room_creation(
&facts(Some("member"), true, Visibility::Open),
&default_policy(),
)
.expect("the shipped default permits open rooms for members");
}
#[test]
fn a_member_may_create_an_attributed_room() {
decide_room_creation(
&facts(Some("member"), true, Visibility::Attributed),
&default_policy(),
)
.expect("attributed is permitted by default too — the host cannot read it either way");
}
#[test]
fn private_is_denied_until_an_operator_enables_it() {
let err = decide_room_creation(
&facts(Some("member"), true, Visibility::Private),
&default_policy(),
)
.expect_err("the shipped default denies private");
assert!(
matches!(err, AppError::Forbidden(ref m) if m.contains("private")),
"the refusal must name the tier it refused: {err:?}"
);
}
#[test]
fn a_stranger_may_not_create_a_room_here() {
let err = decide_room_creation(&facts(None, false, Visibility::Open), &default_policy())
.expect_err("a non-member is refused by the shipped default");
assert!(
matches!(err, AppError::Forbidden(ref m) if m.contains("not-a-member")),
"the refusal must carry the policy's own code: {err:?}"
);
}
#[test]
fn a_policy_that_decides_nothing_refuses() {
let silent = compile(
"package vtc.rooms\n\nimport rego.v1\n\nunrelated := true\n",
uuid::Uuid::nil(),
)
.expect("a policy with no decision rule still compiles");
let err = decide_room_creation(&facts(Some("member"), true, Visibility::Open), &silent)
.expect_err("no decision is a refusal");
assert!(
matches!(err, AppError::Forbidden(ref m) if m.contains("no-decision")),
"got {err:?}"
);
}
#[test]
fn a_threaded_verdict_is_refused() {
let refers = compile(
"package vtc.rooms\n\nimport rego.v1\n\n\
decision := {\"effect\": \"refer\", \"with\": {\"code\": \"ask-an-admin\"}}\n",
uuid::Uuid::nil(),
)
.expect("compiles");
let err = decide_room_creation(&facts(Some("member"), true, Visibility::Open), &refers)
.expect_err("refer cannot be honoured here");
assert!(
matches!(err, AppError::Forbidden(ref m) if m.contains("unsupported-verdict")),
"got {err:?}"
);
}
#[test]
fn an_unsigned_registration_never_reaches_a_policy() {
let err = VerifiedRoomCreation::assemble(RoomCreationFacts {
now: Utc::now(),
actor: Actor {
did: " ".into(),
role: None,
member: false,
},
room: RoomRequest {
room_id: "did:webvh:room.example".into(),
visibility: Visibility::Open,
owner_did: "did:key:zCreator".into(),
retention_days: None,
community_owned: false,
},
})
.expect_err("an empty actor is refused at the gate");
assert!(matches!(err, AppError::Forbidden(_)), "got {err:?}");
}
}