use chrono::{DateTime, Duration, Utc};
use vta_sdk::protocols::vetting::{
VETTING_REQUEST_ERR_CAPACITY, VETTING_REQUEST_ERR_DECLINED, VETTING_REQUEST_ERR_INVALID_TICKET,
VETTING_REQUEST_ERR_METHOD_UNAVAILABLE, VETTING_REQUEST_ERR_NOT_ELIGIBLE,
VETTING_VETTER_PROFILE_ERR_NOT_ELIGIBLE, VETTING_VETTER_RESEND_ERR_NOT_GRANTED, vetters,
};
use super::book::VettingBook;
use crate::config::account::PersonaId;
pub const QUERY_TIMEOUT: Duration = Duration::seconds(30);
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum QueryKind {
Manifest,
VetterList,
VetterProfile,
VetterResend,
PcsRoot,
PcsTokens,
PcsEventMode,
PcsChallenge,
}
impl QueryKind {
#[must_use]
pub fn describe(self) -> &'static str {
match self {
QueryKind::Manifest => "its vetting requirements",
QueryKind::VetterList => "its vetter directory",
QueryKind::VetterProfile => "your vetter profile",
QueryKind::VetterResend => "your vetter credential",
QueryKind::PcsRoot => "your hidden-vetting credential",
QueryKind::PcsTokens => "your attestation tokens",
QueryKind::PcsEventMode => "your place at the event",
QueryKind::PcsChallenge => "the challenge for your submission",
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CommunityQuery {
pub document_id: String,
pub community: String,
pub persona: PersonaId,
pub kind: QueryKind,
pub sent_at: DateTime<Utc>,
}
#[derive(Clone, Debug)]
pub enum CommunityAnswer {
Manifest {
community: String,
},
Vetters {
query: String,
community: String,
page: vetters::list::v0_1::Response,
},
ProfileStored {
community: String,
listed: bool,
updated_at: DateTime<Utc>,
},
Resent {
community: String,
valid_until: DateTime<Utc>,
},
Refused {
query: String,
community: String,
kind: QueryKind,
code: String,
message: Option<String>,
},
Unreadable {
query: String,
community: String,
kind: QueryKind,
detail: String,
},
}
impl CommunityAnswer {
#[must_use]
pub fn community(&self) -> &str {
match self {
CommunityAnswer::Manifest { community }
| CommunityAnswer::Vetters { community, .. }
| CommunityAnswer::ProfileStored { community, .. }
| CommunityAnswer::Resent { community, .. }
| CommunityAnswer::Refused { community, .. }
| CommunityAnswer::Unreadable { community, .. } => community,
}
}
}
#[must_use]
pub fn request_refusal_words(code: &str) -> &'static str {
match code {
VETTING_REQUEST_ERR_INVALID_TICKET => {
"their ticket did not match — it may have been used up, expired, or been issued for \
a different community or a different persona of theirs. Ask them for a fresh one."
}
VETTING_REQUEST_ERR_CAPACITY => {
"they have as many open requests as they take. Try again later, or ask another \
vetter."
}
VETTING_REQUEST_ERR_NOT_ELIGIBLE => {
"the community does not count them as a vetter — their grant may have lapsed or \
been revoked. Ask another vetter."
}
VETTING_REQUEST_ERR_METHOD_UNAVAILABLE => {
"their ticket does not offer the way of meeting you asked for."
}
VETTING_REQUEST_ERR_DECLINED => "they declined to take it.",
_ => "they refused it, and gave no reason this client understands.",
}
}
#[must_use]
pub fn refusal_words(kind: QueryKind, community: &str, code: &str) -> String {
match code {
VETTING_VETTER_PROFILE_ERR_NOT_ELIGIBLE => format!(
"{community} did not accept your profile: it does not count you as an active member \
holding a live vetter credential. If it named you a vetter, ask it to resend the \
credential."
),
VETTING_VETTER_RESEND_ERR_NOT_GRANTED => format!(
"{community} holds no live vetter credential for you — it has not named you a \
vetter, or the grant expired or was revoked. Ask its admins for the vetter role."
),
c if c.ends_with("permissionDenied") => format!(
"{community} would not answer this persona about {} ({code}).",
kind.describe()
),
c if c.ends_with("malformedRequest") => format!(
"{community} could not read the request for {} ({code}) — this client and the \
community disagree about the task, so try again after updating.",
kind.describe()
),
_ => format!(
"{community} refused the request for {} ({code}).",
kind.describe()
),
}
}
impl VettingBook {
pub fn ask(&mut self, query: CommunityQuery) {
self.queries.push(query);
}
#[must_use]
pub fn asked(&self, community: &str) -> bool {
self.queries.iter().any(|q| q.community == community)
}
#[must_use]
pub fn waiting_on(&self, community: &str, kind: QueryKind) -> Option<&CommunityQuery> {
self.queries
.iter()
.find(|q| q.community == community && q.kind == kind)
}
pub fn take_query(
&mut self,
community: &str,
thread: &str,
kind: Option<QueryKind>,
) -> Option<CommunityQuery> {
let i = self.queries.iter().position(|q| {
q.community == community && q.document_id == thread && kind.is_none_or(|k| q.kind == k)
})?;
Some(self.queries.remove(i))
}
pub fn take_manifest_queries(
&mut self,
community: &str,
thread: Option<&str>,
) -> Vec<CommunityQuery> {
let threaded =
thread.and_then(|t| self.take_query(community, t, Some(QueryKind::Manifest)));
let mut taken: Vec<CommunityQuery> = threaded.into_iter().collect();
let (answered, rest): (Vec<_>, Vec<_>) = std::mem::take(&mut self.queries)
.into_iter()
.partition(|q| q.community == community && q.kind == QueryKind::Manifest);
self.queries = rest;
taken.extend(answered);
taken
}
pub fn forget_query(&mut self, document_id: &str) -> Option<CommunityQuery> {
let i = self
.queries
.iter()
.position(|q| q.document_id == document_id)?;
Some(self.queries.remove(i))
}
pub fn expire_queries(&mut self, now: DateTime<Utc>, after: Duration) -> Vec<CommunityQuery> {
let (expired, rest): (Vec<_>, Vec<_>) = std::mem::take(&mut self.queries)
.into_iter()
.partition(|q| now - q.sent_at >= after);
self.queries = rest;
expired
}
}
#[cfg(test)]
mod tests {
use super::*;
fn query(id: &str, community: &str, kind: QueryKind, sent_at: DateTime<Utc>) -> CommunityQuery {
CommunityQuery {
document_id: id.into(),
community: community.into(),
persona: PersonaId::new(),
kind,
sent_at,
}
}
#[test]
fn a_reply_takes_only_the_question_it_threads_on() {
let now = Utc::now();
let mut book = VettingBook::default();
book.ask(query("q1", "did:web:a", QueryKind::VetterList, now));
book.ask(query("q2", "did:web:a", QueryKind::VetterProfile, now));
assert!(
book.take_query("did:web:b", "q1", None).is_none(),
"another community cannot answer our question"
);
assert!(
book.take_query("did:web:a", "q2", Some(QueryKind::VetterList))
.is_none(),
"a directory page does not answer a profile request"
);
assert_eq!(
book.take_query("did:web:a", "q1", Some(QueryKind::VetterList))
.map(|q| q.document_id),
Some("q1".to_string())
);
assert!(book.take_query("did:web:a", "q1", None).is_none(), "once");
assert!(
book.waiting_on("did:web:a", QueryKind::VetterProfile)
.is_some()
);
}
#[test]
fn a_manifest_answers_every_question_about_it() {
let now = Utc::now();
let mut book = VettingBook::default();
book.ask(query("m1", "did:web:a", QueryKind::Manifest, now));
book.ask(query("m2", "did:web:a", QueryKind::Manifest, now));
book.ask(query("m3", "did:web:b", QueryKind::Manifest, now));
let taken = book.take_manifest_queries("did:web:a", Some("unrelated-thread"));
assert_eq!(taken.len(), 2);
assert_eq!(book.queries.len(), 1);
}
#[test]
fn unanswered_questions_expire_and_are_not_persisted() {
let now = Utc::now();
let mut book = VettingBook::default();
book.ask(query(
"old",
"did:web:a",
QueryKind::VetterResend,
now - QUERY_TIMEOUT,
));
book.ask(query("new", "did:web:a", QueryKind::VetterList, now));
let expired = book.expire_queries(now, QUERY_TIMEOUT);
assert_eq!(expired.len(), 1);
assert_eq!(expired[0].document_id, "old");
assert!(book.is_empty(), "questions are memory only");
assert_eq!(serde_json::to_value(&book).unwrap(), serde_json::json!({}));
}
#[test]
fn refusals_say_what_to_do_next() {
let not_granted = refusal_words(
QueryKind::VetterResend,
"Kernel",
VETTING_VETTER_RESEND_ERR_NOT_GRANTED,
);
assert!(not_granted.contains("has not named you a vetter"));
let not_eligible = refusal_words(
QueryKind::VetterProfile,
"Kernel",
VETTING_VETTER_PROFILE_ERR_NOT_ELIGIBLE,
);
assert!(not_eligible.contains("resend"));
assert!(
refusal_words(QueryKind::VetterList, "Kernel", "permissionDenied")
.contains("would not answer this persona")
);
assert!(
refusal_words(QueryKind::VetterList, "Kernel", "malformedRequest").contains("disagree")
);
}
}