use serde_json::Value;
use vti_common::error::AppError;
use crate::auth::AuthClaims;
use crate::policy::effects::{Effect, StatePin};
use crate::server::AppState;
#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TaskPlan {
pub effects: Vec<Effect>,
pub state_pin: Option<StatePin>,
pub guards: Guards,
pub subject_context: Option<String>,
#[serde(default = "default_true")]
pub requester_authorized: bool,
}
fn default_true() -> bool {
true
}
#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Guards {
pub webvh_path_counter: Option<WebvhPathCounter>,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct WebvhPathCounter {
pub base_path: String,
pub counter: u32,
}
pub(super) async fn plan_task(
state: &AppState,
auth: &AuthClaims,
type_uri: &str,
payload: &Value,
) -> Result<Option<TaskPlan>, AppError> {
#[cfg(feature = "webvh")]
if type_uri == vta_sdk::trust_tasks::TASK_WEBVH_DIDS_UPDATE_1_0 {
return plan_webvh_update(state, auth, payload).await.map(Some);
}
let _ = (state, auth, type_uri, payload);
Ok(None)
}
#[cfg(feature = "webvh")]
async fn plan_webvh_update(
state: &AppState,
auth: &AuthClaims,
payload: &Value,
) -> Result<TaskPlan, AppError> {
use crate::operations::did_webvh;
let req: super::webvh::UpdateDidWithDid = serde_json::from_value(payload.clone())
.map_err(|e| AppError::Validation(format!("invalid webvh update payload: {e}")))?;
let options = super::webvh::update_body_to_options(req.body)
.map_err(|e| AppError::Validation(format!("invalid webvh update options: {e:?}")))?;
let did_resolver = state
.did_resolver
.as_ref()
.ok_or_else(|| AppError::Internal("DID resolver not available".into()))?;
let deps = did_webvh::WebvhDeps::from_app_state(state, did_resolver);
let plan = did_webvh::plan_did_webvh_update(&deps, auth, &req.did, options)
.await
.map_err(|e| AppError::Internal(format!("webvh update dry-run failed: {e}")))?;
Ok(TaskPlan {
effects: plan.to_effects(),
state_pin: Some(plan.state_pin()),
guards: Guards {
webvh_path_counter: Some(WebvhPathCounter {
base_path: plan.base_path.clone(),
counter: plan.path_counter_pin,
}),
},
subject_context: Some(plan.subject_context.clone()),
requester_authorized: plan.requester_authorized,
})
}
pub(super) async fn assert_plan_still_holds(
state: &AppState,
auth: &AuthClaims,
type_uri: &str,
payload: &Value,
approved_pin: Option<&StatePin>,
approved_guards: &Guards,
) -> Result<(), String> {
let current = match plan_task(state, auth, type_uri, payload).await {
Ok(Some(p)) => p,
Ok(None) => return Ok(()),
Err(e) => return Err(format!("could not re-plan the task at execution: {e}")),
};
check_unchanged(approved_pin, approved_guards, ¤t)
}
fn check_unchanged(
approved_pin: Option<&StatePin>,
approved_guards: &Guards,
current: &TaskPlan,
) -> Result<(), String> {
if current.state_pin.as_ref() != approved_pin {
return Err(format!(
"the subject's state changed while this task was awaiting approval (approved against \
version {}, now {}). Re-submit to be shown the current effects.",
approved_pin.map_or("none", |p| p.version.as_str()),
current
.state_pin
.as_ref()
.map_or("none", |p| p.version.as_str()),
));
}
if ¤t.guards != approved_guards {
return Err(
"the executor's key derivation moved while this task was awaiting approval, so it \
would no longer install the keys the approver was shown. Re-submit to be shown the \
current effects."
.to_string(),
);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn pin(v: &str) -> StatePin {
StatePin {
resource: "did:webvh:example".into(),
version: v.into(),
}
}
fn guards(counter: u32) -> Guards {
Guards {
webvh_path_counter: Some(WebvhPathCounter {
base_path: "m/1'/2'".into(),
counter,
}),
}
}
fn plan(pin_v: &str, counter: u32) -> TaskPlan {
TaskPlan {
effects: vec![],
state_pin: Some(pin(pin_v)),
guards: guards(counter),
subject_context: None,
requester_authorized: true,
}
}
#[test]
fn an_unmoved_world_passes() {
assert!(check_unchanged(Some(&pin("3-Qm")), &guards(7), &plan("3-Qm", 7)).is_ok());
}
#[test]
fn a_moved_subject_is_refused() {
let err = check_unchanged(Some(&pin("3-Qm")), &guards(7), &plan("4-Qm", 7)).unwrap_err();
assert!(err.contains("3-Qm") && err.contains("4-Qm"), "{err}");
}
#[test]
fn a_moved_derivation_counter_is_refused() {
let err = check_unchanged(Some(&pin("3-Qm")), &guards(7), &plan("3-Qm", 8)).unwrap_err();
assert!(err.contains("key derivation moved"), "{err}");
}
#[test]
fn a_pin_appearing_from_nowhere_is_refused() {
let unplanned = TaskPlan::default();
assert!(check_unchanged(Some(&pin("3-Qm")), &Guards::default(), &unplanned).is_err());
}
}