pub mod criteria;
pub mod orchestrate;
pub mod retention;
pub mod storage;
#[cfg(test)]
mod invitation_e2e_test;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_json::Value as JsonValue;
use uuid::Uuid;
pub use orchestrate::{
HolderBinding, JOIN_REQUEST_SUBMIT_DOMAIN_TAG, JoinSubmitOutcome, SubmitRefusal,
SupplementRefusal, decide_join, emit_admit_audit, realize_join_verdict, submit_inner,
supplement_inner,
};
pub use retention::{JoinRequestsConfig, RetentionSweeper, default_retention_days};
pub use storage::{
CredentialResends, JOIN_REQUEST_EXTENSIONS_MAX_BYTES, JOIN_REQUEST_VP_MAX_BYTES,
StoredCriterion, StoredVettingFacts, delete_join_request, get_credential_resends,
get_criterion, get_join_request, get_vetting_facts, list_join_requests,
list_join_requests_filtered, list_join_requests_paginated, store_credential_resends,
store_criterion, store_join_request, store_vetting_facts,
};
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
#[derive(utoipa::ToSchema)]
pub enum JoinStatus {
Pending,
Approved,
Rejected,
Withdrawn,
Deferred,
}
impl JoinStatus {
pub fn is_terminal_retainable(self) -> bool {
matches!(self, JoinStatus::Rejected | JoinStatus::Withdrawn)
}
}
impl std::fmt::Display for JoinStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
JoinStatus::Pending => f.write_str("pending"),
JoinStatus::Approved => f.write_str("approved"),
JoinStatus::Rejected => f.write_str("rejected"),
JoinStatus::Withdrawn => f.write_str("withdrawn"),
JoinStatus::Deferred => f.write_str("deferred"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
#[derive(utoipa::ToSchema)]
pub struct JoinRequest {
pub id: Uuid,
pub applicant_did: String,
pub vp: JsonValue,
#[serde(default)]
pub vp_claims: JsonValue,
pub submitted_at: DateTime<Utc>,
pub status: JoinStatus,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub policy_decision: Option<JsonValue>,
#[serde(default)]
pub registry_consent: bool,
#[serde(default, skip_serializing_if = "JsonValue::is_null")]
pub extensions: JsonValue,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub attributes: Vec<SubmittedAttribute>,
#[serde(default)]
pub decision: Option<JoinDecision>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
#[derive(utoipa::ToSchema)]
pub struct JoinDecision {
pub code: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
pub decided_at: DateTime<Utc>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, utoipa::ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct SubmittedAttribute {
pub r#type: String,
pub value: JsonValue,
}
impl JoinRequest {
pub fn new(applicant_did: impl Into<String>, vp: JsonValue) -> Self {
Self {
id: Uuid::new_v4(),
applicant_did: applicant_did.into(),
vp,
vp_claims: JsonValue::Null,
submitted_at: Utc::now(),
status: JoinStatus::Pending,
policy_decision: None,
registry_consent: false,
extensions: JsonValue::Null,
attributes: Vec::new(),
decision: None,
}
}
pub fn decision_for_applicant(
&self,
) -> Option<(String, Option<String>, Option<DateTime<Utc>>)> {
if self.status != JoinStatus::Rejected {
return None;
}
if let Some(d) = &self.decision {
return Some((d.code.clone(), d.reason.clone(), Some(d.decided_at)));
}
let verdict = self.policy_decision.clone()?;
match serde_json::from_value::<crate::ceremony::Verdict>(verdict).ok()? {
crate::ceremony::Verdict::Deny(d) => Some((d.code, d.reason, None)),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum JoinTransport {
Rest,
DIDComm,
Tsp,
}
impl JoinTransport {
pub fn as_str(self) -> &'static str {
match self {
JoinTransport::Rest => "rest",
JoinTransport::DIDComm => "didcomm",
JoinTransport::Tsp => "tsp",
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn join_status_terminal_retainable_only_for_rejected_and_withdrawn() {
for (status, expected) in [
(JoinStatus::Pending, false),
(JoinStatus::Approved, false),
(JoinStatus::Rejected, true),
(JoinStatus::Withdrawn, true),
(JoinStatus::Deferred, false),
] {
assert_eq!(status.is_terminal_retainable(), expected, "{status:?}");
}
}
#[test]
fn join_status_display_is_lowercase() {
assert_eq!(JoinStatus::Pending.to_string(), "pending");
assert_eq!(JoinStatus::Approved.to_string(), "approved");
assert_eq!(JoinStatus::Deferred.to_string(), "deferred");
}
#[test]
fn join_request_new_uses_pending_status() {
let r = JoinRequest::new("did:key:z", serde_json::json!({}));
assert_eq!(r.status, JoinStatus::Pending);
assert_eq!(r.policy_decision, None);
assert!(!r.registry_consent);
}
#[test]
fn join_transport_str_round_trip() {
assert_eq!(JoinTransport::Rest.as_str(), "rest");
assert_eq!(JoinTransport::DIDComm.as_str(), "didcomm");
}
fn rejected(decision: Option<JoinDecision>, policy: Option<JsonValue>) -> JoinRequest {
let mut r = JoinRequest::new("did:key:zApplicant", serde_json::json!({}));
r.status = JoinStatus::Rejected;
r.decision = decision;
r.policy_decision = policy;
r
}
#[test]
fn a_stored_decision_is_returned_verbatim() {
let at = Utc::now();
let r = rejected(
Some(JoinDecision {
code: "membership-required".into(),
reason: Some("members of the parent community only".into()),
decided_at: at,
}),
None,
);
assert_eq!(
r.decision_for_applicant(),
Some((
"membership-required".to_string(),
Some("members of the parent community only".to_string()),
Some(at),
))
);
}
#[test]
fn a_legacy_auto_deny_row_falls_back_to_the_policy_verdict() {
let r = rejected(
None,
Some(serde_json::json!({
"effect": "deny",
"with": { "code": "closed", "reason": "not accepting applications" }
})),
);
assert_eq!(
r.decision_for_applicant(),
Some((
"closed".to_string(),
Some("not accepting applications".to_string()),
None,
)),
"the code and reason survive; the never-recorded timestamp does not"
);
}
#[test]
fn a_legacy_admin_reject_row_yields_nothing() {
assert_eq!(rejected(None, None).decision_for_applicant(), None);
}
#[test]
fn only_a_rejected_request_has_a_refusal() {
let mut deferred = rejected(
None,
Some(serde_json::json!({
"effect": "request_more",
"with": { "needs": ["agreed:code-of-conduct"] }
})),
);
assert_eq!(deferred.decision_for_applicant(), None);
deferred.status = JoinStatus::Deferred;
deferred.decision = Some(JoinDecision {
code: "should-not-surface".into(),
reason: None,
decided_at: Utc::now(),
});
assert_eq!(deferred.decision_for_applicant(), None);
}
}