use axum::Json;
use axum::extract::{Path, Query, State};
use chrono::Utc;
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value as JsonValue, json};
use vti_common::error::AppError;
use crate::acl::get_acl_entry;
use crate::auth::AuthClaims;
use crate::ceremony::{
self, Actor, Context, Evidence, Facts, MemberState, Purpose, State as FactsState, Subject,
Verdict, VerifiedFacts, effects::EffectPlan,
};
use crate::community::load_profile;
use crate::members::{get_member, list_members};
use crate::policy::load_active_compiled;
use crate::policy::model::PolicyPurpose;
use crate::server::AppState;
pub const DIRECTORY_FIELD_WHITELIST: [&str; 4] = ["did", "role", "joined_at", "status"];
#[derive(Debug, Default, Deserialize)]
pub struct DirectoryQuery {
#[serde(default)]
pub fields: Option<String>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DirectoryResponse {
pub subject: String,
pub fields: Map<String, JsonValue>,
}
pub async fn query(
viewer: AuthClaims,
State(state): State<AppState>,
Path(subject_did): Path<String>,
Query(q): Query<DirectoryQuery>,
) -> Result<Json<DirectoryResponse>, AppError> {
let facts = assemble_directory_facts(&state, &viewer, &subject_did, q.fields).await?;
let verified = VerifiedFacts::assemble(facts)?;
let policy = load_active_compiled(
&state.active_policies_ks,
&state.policies_ks,
PolicyPurpose::Directory,
)
.await?;
let verdict = ceremony::decide(&verified, &policy)?;
match &verdict {
Verdict::Allow(_) => {
let whitelist: Vec<String> = DIRECTORY_FIELD_WHITELIST
.iter()
.map(|s| s.to_string())
.collect();
match ceremony::plan(&verified, &verdict, &whitelist)? {
EffectPlan::Project { fields } => Ok(Json(DirectoryResponse {
subject: subject_did,
fields,
})),
other => Err(AppError::Internal(format!(
"directory allow produced a non-projection effect: {other:?}"
))),
}
}
Verdict::Deny(d) => Err(AppError::Forbidden(format!(
"directory access denied ({}){}",
d.code,
d.reason
.as_deref()
.map(|r| format!(": {r}"))
.unwrap_or_default(),
))),
Verdict::Refer(_) | Verdict::RequestMore(_) => Err(AppError::Internal(
"directory policy returned a non-terminal verdict; directory is synchronous".into(),
)),
}
}
async fn assemble_directory_facts(
state: &AppState,
viewer: &AuthClaims,
subject_did: &str,
fields_hint: Option<String>,
) -> Result<Facts, AppError> {
let actor_role = get_acl_entry(&state.acl_ks, &viewer.did)
.await?
.map(|e| e.role.to_string());
let subject_member = match get_member(&state.members_ks, subject_did).await? {
Some(m) => {
let role = get_acl_entry(&state.acl_ks, subject_did)
.await?
.map(|e| e.role.to_string())
.unwrap_or_else(|| "member".to_string());
let status = if m.removed_at.is_some() {
"removed"
} else {
"active"
};
Some(MemberState {
role,
status: status.to_string(),
joined_at: m.joined_at,
personhood: None,
})
}
None => None,
};
let community_did = load_profile(&state.community_ks)
.await?
.map(|p| p.community_did)
.unwrap_or_default();
let member_count = list_members(&state.members_ks).await?.len() as u64;
let request = fields_hint.map(|raw| {
let fields: Vec<String> = raw
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
json!({ "fields_requested": fields })
});
Ok(Facts {
purpose: Purpose::Directory,
now: Utc::now(),
actor: Actor {
did: viewer.did.clone(),
role: actor_role,
authenticated: true,
},
subject: Subject {
did: subject_did.to_string(),
},
context: Context {
community_did,
channel: "rest".to_string(),
member_count,
},
evidence: Evidence {
invitation: None,
presentation: None,
request,
},
state: FactsState { subject_member },
})
}