use std::path::PathBuf;
use crate::client::VtaClient;
use crate::did_secrets::DidSecretsBundle;
use crate::error::VtaError;
use crate::session::{SessionStore, TransportChoice};
pub const DEFAULT_SERVICE_NAME: &str = "pnm-cli";
const PNM_SESSION_PREFIX: &str = "vta:";
pub fn pnm_session_key(slug: &str) -> String {
if slug.starts_with(PNM_SESSION_PREFIX) {
slug.to_string()
} else {
format!("{PNM_SESSION_PREFIX}{slug}")
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ConnectMode {
DidWebvhBundle {
agent_did: String,
mediator_did: String,
},
DidKey {
agent_did: String,
mediator_did: String,
},
Token {
url: String,
},
Session {
key: String,
},
}
impl ConnectMode {
pub fn label(&self) -> &'static str {
match self {
Self::DidWebvhBundle { .. } => "did:webvh-didcomm",
Self::DidKey { .. } => "did:key-didcomm",
Self::Token { .. } => "token-rest",
Self::Session { .. } => "session",
}
}
pub fn is_dedicated_agent(&self) -> bool {
matches!(self, Self::DidWebvhBundle { .. } | Self::DidKey { .. })
}
}
#[derive(Debug, Clone, Default)]
pub struct AgentConnect {
pub agent_secrets: Option<String>,
pub agent_did: Option<String>,
pub agent_key: Option<String>,
pub vta_did: Option<String>,
pub mediator_did: Option<String>,
pub url: Option<String>,
pub token: Option<String>,
pub session_key: Option<String>,
pub service_name: Option<String>,
pub sessions_dir: Option<PathBuf>,
pub transport: TransportChoice,
}
macro_rules! setter {
($name:ident, $doc:literal) => {
#[doc = $doc]
pub fn $name(mut self, v: impl Into<String>) -> Self {
self.$name = Some(v.into());
self
}
};
}
impl AgentConnect {
setter!(
agent_secrets,
"Set the did:webvh secrets bundle (path or inline JSON)."
);
setter!(agent_did, "Set the agent `did:key`.");
setter!(agent_key, "Set the agent Ed25519 signing key (multibase).");
setter!(vta_did, "Set the VTA's DID.");
setter!(mediator_did, "Set the mediator DID.");
setter!(url, "Set the VTA REST URL.");
setter!(token, "Set the bearer token (token mode).");
setter!(
session_key,
"Set the session key / VTA slug (session mode)."
);
setter!(
service_name,
"Set the service name sessions are stored under."
);
pub fn sessions_dir(mut self, dir: impl Into<PathBuf>) -> Self {
self.sessions_dir = Some(dir.into());
self
}
pub fn transport(mut self, transport: TransportChoice) -> Self {
self.transport = transport;
self
}
pub fn mode(&self) -> Result<ConnectMode, VtaError> {
let has_didkey_identity = self.agent_did.is_some() || self.agent_key.is_some();
if self.agent_secrets.is_some() && has_didkey_identity {
return Err(VtaError::Validation(
"agent_secrets (did:webvh bundle) and agent_did/agent_key (did:key) are \
mutually exclusive — supply one identity, not both"
.into(),
));
}
if let Some(raw) = self.agent_secrets.as_deref() {
let bundle = load_bundle(raw)?;
let (_, mediator_did) = self.require_didcomm_targets("did:webvh (agent_secrets)")?;
return Ok(ConnectMode::DidWebvhBundle {
agent_did: bundle.did,
mediator_did,
});
}
match (
self.agent_did.as_deref(),
self.agent_key.as_deref(),
self.vta_did.as_deref(),
self.mediator_did.as_deref(),
) {
(Some(agent_did), Some(_), Some(_), Some(mediator_did)) => {
return Ok(ConnectMode::DidKey {
agent_did: agent_did.to_string(),
mediator_did: mediator_did.to_string(),
});
}
(None, None, _, _) if !has_didkey_identity => {}
_ => {
return Err(VtaError::Validation(format!(
"did:key DIDComm mode needs all of agent_did, agent_key, vta_did, \
mediator_did (or none of them); missing: {}",
missing_fields(&[
("agent_did", self.agent_did.is_some()),
("agent_key", self.agent_key.is_some()),
("vta_did", self.vta_did.is_some()),
("mediator_did", self.mediator_did.is_some()),
])
)));
}
}
if let (Some(url), Some(token)) = (self.url.as_deref(), self.token.as_deref())
&& !token.is_empty()
{
return Ok(ConnectMode::Token {
url: url.to_string(),
});
}
let key = self.session_key.as_deref().ok_or_else(|| {
VtaError::Validation(
"no connection configured: supply a session_key (an existing `pnm` login), \
url + token, or an agent identity (agent_did + agent_key + vta_did + \
mediator_did)"
.into(),
)
})?;
Ok(ConnectMode::Session {
key: key.to_string(),
})
}
pub async fn connect(&self) -> Result<VtaClient, VtaError> {
match self.mode()? {
ConnectMode::DidWebvhBundle { .. } => {
let raw = self.agent_secrets.as_deref().expect("mode checked");
let bundle = load_bundle(raw)?;
let (vta_did, mediator_did) =
self.require_didcomm_targets("did:webvh (agent_secrets)")?;
VtaClient::connect_didcomm_bundle(
&bundle,
&vta_did,
&mediator_did,
self.url.clone(),
)
.await
}
ConnectMode::DidKey { .. } => {
VtaClient::connect_didcomm(
self.agent_did.as_deref().expect("mode checked"),
self.agent_key.as_deref().expect("mode checked"),
self.vta_did.as_deref().expect("mode checked"),
self.mediator_did.as_deref().expect("mode checked"),
self.url.clone(),
)
.await
}
ConnectMode::Token { url } => {
let client = VtaClient::new(&url);
client
.set_token_async(self.token.clone().expect("mode checked"))
.await;
Ok(client)
}
ConnectMode::Session { key } => {
let service = self
.service_name
.as_deref()
.unwrap_or(DEFAULT_SERVICE_NAME)
.to_string();
let dir = match &self.sessions_dir {
Some(d) => d.clone(),
None => default_sessions_dir()?,
};
SessionStore::new(&service, dir)
.connect_with_transport(&key, self.url.as_deref(), None, self.transport)
.await
.map_err(|e| VtaError::Auth(e.to_string()))
}
}
}
fn require_didcomm_targets(&self, mode: &str) -> Result<(String, String), VtaError> {
match (self.vta_did.as_deref(), self.mediator_did.as_deref()) {
(Some(v), Some(m)) => Ok((v.to_string(), m.to_string())),
_ => Err(VtaError::Validation(format!(
"{mode} DIDComm mode needs vta_did and mediator_did; missing: {}",
missing_fields(&[
("vta_did", self.vta_did.is_some()),
("mediator_did", self.mediator_did.is_some()),
])
))),
}
}
}
fn missing_fields(fields: &[(&str, bool)]) -> String {
let missing: Vec<&str> = fields
.iter()
.filter(|(_, present)| !present)
.map(|(name, _)| *name)
.collect();
if missing.is_empty() {
"(none)".to_string()
} else {
missing.join(", ")
}
}
fn load_bundle(raw: &str) -> Result<DidSecretsBundle, VtaError> {
let json = if std::path::Path::new(raw).exists() {
std::fs::read_to_string(raw).map_err(|e| {
VtaError::Validation(format!("reading agent secrets bundle from `{raw}`: {e}"))
})?
} else {
raw.to_string()
};
serde_json::from_str(&json).map_err(|e| {
VtaError::Validation(format!(
"parsing agent secrets bundle (path or inline JSON expected): {e}"
))
})
}
fn default_sessions_dir() -> Result<PathBuf, VtaError> {
Ok(dirs::config_dir()
.ok_or_else(|| {
VtaError::Validation(
"could not determine the user config directory; set sessions_dir explicitly".into(),
)
})?
.join("pnm"))
}
#[cfg(test)]
mod tests {
use super::*;
fn didkey() -> AgentConnect {
AgentConnect::default()
.agent_did("did:key:zAgent")
.agent_key("zKey")
.vta_did("did:key:zVta")
.mediator_did("did:key:zMed")
}
#[test]
fn didkey_mode_needs_all_four_fields() {
assert_eq!(
didkey().mode().unwrap(),
ConnectMode::DidKey {
agent_did: "did:key:zAgent".into(),
mediator_did: "did:key:zMed".into(),
}
);
}
#[test]
fn half_configured_didkey_is_an_error_naming_the_gap() {
let err = AgentConnect::default()
.agent_did("did:key:zAgent")
.session_key("my-vta")
.mode()
.unwrap_err();
let msg = err.to_string();
assert!(msg.contains("agent_key"), "{msg}");
assert!(msg.contains("vta_did"), "{msg}");
assert!(!msg.contains("session"), "must not fall through: {msg}");
}
#[test]
fn bundle_and_didkey_together_are_rejected() {
let err = AgentConnect::default()
.agent_secrets(r#"{"did":"did:webvh:a:b","secrets":[]}"#)
.agent_did("did:key:zAgent")
.mode()
.unwrap_err();
assert!(err.to_string().contains("mutually exclusive"));
}
#[test]
fn bundle_mode_reports_the_bundle_did() {
let mode = AgentConnect::default()
.agent_secrets(r#"{"did":"did:webvh:abc:example.com:a","secrets":[]}"#)
.vta_did("did:key:zVta")
.mediator_did("did:key:zMed")
.mode()
.unwrap();
assert_eq!(
mode,
ConnectMode::DidWebvhBundle {
agent_did: "did:webvh:abc:example.com:a".into(),
mediator_did: "did:key:zMed".into(),
}
);
}
#[test]
fn bundle_without_targets_names_them() {
let err = AgentConnect::default()
.agent_secrets(r#"{"did":"did:webvh:a:b","secrets":[]}"#)
.mode()
.unwrap_err();
let msg = err.to_string();
assert!(msg.contains("vta_did"), "{msg}");
assert!(msg.contains("mediator_did"), "{msg}");
}
#[test]
fn token_mode_beats_session_mode() {
let mode = AgentConnect::default()
.url("https://vta.example")
.token("jwt")
.session_key("my-vta")
.mode()
.unwrap();
assert_eq!(
mode,
ConnectMode::Token {
url: "https://vta.example".into()
}
);
}
#[test]
fn empty_token_falls_through_to_session() {
let mode = AgentConnect::default()
.url("https://vta.example")
.token("")
.session_key("my-vta")
.mode()
.unwrap();
assert_eq!(
mode,
ConnectMode::Session {
key: "my-vta".into()
}
);
}
#[test]
fn nothing_configured_names_every_way_in() {
let err = AgentConnect::default().mode().unwrap_err();
let msg = err.to_string();
assert!(msg.contains("session_key"), "{msg}");
assert!(msg.contains("token"), "{msg}");
assert!(msg.contains("agent_did"), "{msg}");
}
#[test]
fn bundle_loads_from_a_file_path() {
let dir = std::env::temp_dir();
let path = dir.join(format!("agent-connect-bundle-{}.json", std::process::id()));
std::fs::write(&path, r#"{"did":"did:webvh:f:example.com:b","secrets":[]}"#).unwrap();
let bundle = load_bundle(path.to_str().unwrap()).unwrap();
assert_eq!(bundle.did, "did:webvh:f:example.com:b");
let _ = std::fs::remove_file(&path);
}
#[test]
fn the_default_sessions_dir_follows_pnm_rather_than_assuming_xdg() {
let dir = default_sessions_dir().expect("a config dir");
assert_eq!(dir.file_name().expect("pnm"), "pnm");
assert_eq!(
dir.parent().expect("parent"),
dirs::config_dir().expect("config dir")
);
}
#[test]
fn pnm_session_keys_carry_the_prefix_and_are_idempotent() {
assert_eq!(pnm_session_key("mine"), "vta:mine");
assert_eq!(pnm_session_key("vta:mine"), "vta:mine");
assert_eq!(pnm_session_key("community:acme"), "vta:community:acme");
}
#[test]
fn only_the_didcomm_modes_are_dedicated_agents() {
assert!(didkey().mode().unwrap().is_dedicated_agent());
assert!(
!AgentConnect::default()
.session_key("my-vta")
.mode()
.unwrap()
.is_dedicated_agent()
);
}
}