use chrono::{DateTime, Utc};
use serde_json::{Value, json};
use tracing::{info, warn};
use trust_tasks_rs::specs::vtc::members::step_up_passkey_notice::v0_1 as notice;
use vti_common::capability_client::build_document;
use crate::error::AppError;
use crate::server::AppState;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Event {
Enrolled,
Revoked,
}
impl Event {
fn as_str(self) -> &'static str {
match self {
Event::Enrolled => "enrolled",
Event::Revoked => "revoked",
}
}
}
pub async fn send(
state: &AppState,
did: &str,
event: Event,
credential_id: &str,
by: &str,
at: DateTime<Utc>,
reason: Option<&str>,
) {
if let Err(e) = try_send(state, did, event, credential_id, by, at, reason).await {
warn!(
error = %e,
did,
by,
event = event.as_str(),
"step-up passkey notice could not be queued"
);
}
}
async fn try_send(
state: &AppState,
did: &str,
event: Event,
credential_id: &str,
by: &str,
at: DateTime<Utc>,
reason: Option<&str>,
) -> Result<(), AppError> {
let vtc_did = state
.config
.read()
.await
.vtc_did
.clone()
.filter(|d| !d.is_empty())
.ok_or_else(|| AppError::Internal("VTC DID not configured".into()))?;
let signer = state
.credential_signer
.as_ref()
.ok_or_else(|| AppError::Internal("credential signer not configured".into()))?;
let payload = notice_payload(did, event, credential_id, by, at, reason)?;
let type_uri = <notice::Payload as trust_tasks_rs::Payload>::TYPE_URI;
let doc = build_document(&vtc_did, did, type_uri, payload);
let mut doc_value = serde_json::to_value(&doc)
.map_err(|e| AppError::Internal(format!("serialise step-up passkey notice: {e}")))?;
signer.sign_operational_doc(&mut doc_value).await?;
crate::member_push::push_trust_task(state, did, doc_value, crate::server::EXCHANGE_DELIVER_BY)
.await?;
info!(
did,
by,
event = event.as_str(),
"step-up passkey notice queued"
);
Ok(())
}
fn notice_payload(
did: &str,
event: Event,
credential_id: &str,
by: &str,
at: DateTime<Utc>,
reason: Option<&str>,
) -> Result<Value, AppError> {
let mut v = json!({
"event": event.as_str(),
"did": did,
"credentialId": credential_id,
"by": by,
"at": at.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
});
if let Some(r) = reason.filter(|r| !r.trim().is_empty()) {
v["reason"] = json!(r);
}
let _checked: notice::Payload = serde_json::from_value(v.clone()).map_err(|e| {
AppError::Internal(format!(
"step-up passkey notice payload rejected by its own schema: {e}"
))
})?;
Ok(v)
}
#[cfg(test)]
mod tests {
use super::*;
fn at() -> DateTime<Utc> {
DateTime::parse_from_rfc3339("2026-09-25T09:00:01Z")
.unwrap()
.with_timezone(&Utc)
}
#[test]
fn enrolled_by_an_administrator_carries_every_field_the_spec_requires() {
let p = notice_payload(
"did:key:zCarol",
Event::Enrolled,
"c3RlcHVwLWNyZWQtY2Fyb2w",
"did:key:zDana",
at(),
Some("Replacing the device Carol reported lost."),
)
.expect("payload builds");
assert_eq!(
p,
json!({
"event": "enrolled",
"did": "did:key:zCarol",
"credentialId": "c3RlcHVwLWNyZWQtY2Fyb2w",
"by": "did:key:zDana",
"at": "2026-09-25T09:00:01Z",
"reason": "Replacing the device Carol reported lost.",
}),
"by differs from did: an administrator acted, and the recipient must see both"
);
}
#[test]
fn a_self_service_change_names_the_member_as_both_did_and_by() {
let p = notice_payload(
"did:key:zCarol",
Event::Revoked,
"c3RlcHVwLWNyZWQtY2Fyb2w",
"did:key:zCarol",
at(),
None,
)
.expect("payload builds");
assert_eq!(p["did"], p["by"], "self-service: by equals did");
assert!(
p.get("reason").is_none(),
"no reason was given, so none is sent — not an empty string"
);
}
#[test]
fn a_blank_reason_is_omitted_rather_than_sent_empty() {
for blank in [Some(""), Some(" ")] {
let p = notice_payload(
"did:key:zCarol",
Event::Revoked,
"c3RlcHVwLWNyZWQtY2Fyb2w",
"did:key:zDana",
at(),
blank,
)
.expect("payload builds");
assert!(
p.get("reason").is_none(),
"blank reason {blank:?} must be absent"
);
}
}
#[test]
fn payload_validates_against_the_published_schema() {
use trust_tasks_rs::validate::ValidatedPayload;
for (event, by, reason) in [
(Event::Enrolled, "did:key:zDana", Some("because")),
(Event::Revoked, "did:key:zCarol", None),
] {
let p = notice_payload(
"did:key:zCarol",
event,
"c3RlcHVwLWNyZWQtY2Fyb2w",
by,
at(),
reason,
)
.expect("payload builds");
notice::Payload::validate_value(&p)
.unwrap_or_else(|e| panic!("payload rejected by its own schema: {e}\n{p:#}"));
}
}
}