use axum::Json;
use axum::extract::{Path, Query, State};
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, Evidence, Facts, Purpose, Verdict, VerifiedFacts, effects::EffectPlan,
};
use crate::ceremony::{FactsInputs, assemble_facts, load_actor_role, member_state};
use crate::members::get_member;
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, utoipa::ToSchema, utoipa::IntoParams)]
#[into_params(parameter_in = Query)]
pub struct DirectoryQuery {
#[serde(default)]
pub fields: Option<String>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
#[derive(utoipa::ToSchema)]
pub struct DirectoryResponse {
pub subject: String,
pub fields: Map<String, JsonValue>,
}
#[utoipa::path(
get, path = "/directory/{did}", tag = "directory",
security(("bearer_jwt" = [])),
params(
("did" = String, Path, description = "Subject DID"),
DirectoryQuery,
),
responses(
(status = 200, description = "Projected subject record", body = DirectoryResponse),
(status = 401, description = "Missing or invalid bearer token"),
(status = 403, description = "Directory access denied"),
),
)]
pub async fn query(
viewer: AuthClaims,
State(state): State<AppState>,
Path(subject_did): Path<String>,
Query(q): Query<DirectoryQuery>,
) -> Result<Json<DirectoryResponse>, AppError> {
vti_common::identifier::validate_did("did", &subject_did)?;
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 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());
Some(member_state(role, Some(&m)))
}
None => None,
};
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 })
});
assemble_facts(
state,
FactsInputs {
purpose: Purpose::Directory,
actor_did: viewer.did.clone(),
actor_role: load_actor_role(state, &viewer.did).await?,
subject_did: subject_did.to_string(),
subject_member,
evidence: Evidence {
invitation: None,
presentation: None,
request,
},
},
)
.await
}