use std::collections::BTreeMap; use abi::{Call, Datetime, Did, StrongRef, TypedStrongRef}; use serde::{Deserialize, Serialize}; use serde_json::{Map, Value, json}; use crate::ids::CallId; pub type Step = abi::Step; pub type Out = abi::Out; pub const MAX_PEERS: usize = 10; static NULL: Value = Value::Null; /// An action's fields: `did` (absent when signed out), `now`, and the form fields, which arrive as /// strings from a form and as JSON from a caller. #[derive(Deserialize)] #[serde(try_from = "Map")] pub struct Args { fields: Map, } impl TryFrom> for Args { type Error = String; fn try_from(fields: Map) -> Result { match fields.get("did") { None | Some(Value::Null) => Ok(Args { fields }), Some(did) => Did::try_from(did.as_str().unwrap_or_default().to_string()).map(|_| Args { fields }), } } } impl Args { pub fn signed_in(&self) -> Result<(), String> { self.arg("did").as_str().map(|_| ()).ok_or("invalid input: missing field `did`".into()) } pub fn me(&self) -> &str { self.arg("did").as_str().unwrap_or_default() } pub fn arg(&self, key: &str) -> &Value { self.fields.get(key).unwrap_or(&NULL) } pub fn created_at(&self) -> Result { Datetime::new(self.arg("now").as_str().unwrap_or_default()).map_err(|e| format!("invalid now: {e}")) } pub fn number(&self, key: &str, default: u64, max: u64) -> u64 { self.arg(key).as_u64().unwrap_or(default).min(max) } } /// Where a peer XRPC goes: an https origin, called directly; did#service, through the PDS (atproto-proxy); /// or a DID's own endpoints (a bidder's offering.endpoints, as { did, endpoints }): the host picks one it /// reaches (iroh, webrtc, https) and sends service auth aud did#pdr_temp_market. #[derive(Clone, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum Route { Service(String), Proxy(String), Peer(String, Value), } impl Route { pub fn call(&self, nsid: &str, input: impl Serialize) -> Call { let call = Call::new("", nsid).input(json!(input)); match self { Route::Service(origin) => call.service(origin), Route::Proxy(target) => call.proxy(target), Route::Peer(target, endpoints) => call.endpoints(target, endpoints.clone()), } } } pub fn did(value: &Value, label: &str) -> Result { match value.as_str() { Some(s) if s.starts_with("did:") && s.len() > 6 && !s.contains(['#', '?', '/']) && !s.chars().any(char::is_whitespace) => Ok(s.to_string()), _ => Err(format!("{label} must be a DID")), } } /// did#service; a bare DID gets `service`. pub fn target(value: &Value, service: &str, label: &str) -> Result { let s = value.as_str().ok_or(format!("{label} must be a DID or did#service"))?; let (d, fragment) = s.split_once('#').unwrap_or((s, service)); let d = did(&json!(d), label)?; if fragment.is_empty() || !fragment.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') { return Err(format!("{label} must be a DID or did#service")); } Ok(format!("{d}#{fragment}")) } /// { did, endpoints } for a peer whose offering lists endpoints; None when it lists none. pub fn peer(did: &str, offering: &Value) -> Option { let endpoints = offering["endpoints"].as_array().filter(|e| !e.is_empty())?; Some(json!({ "did": did, "endpoints": endpoints })) } pub fn route(value: &Value, label: &str) -> Result { if value.is_object() { let endpoints = value["endpoints"].as_array().filter(|e| !e.is_empty() && e.len() <= 8).ok_or(format!("{label} endpoints must be 1 to 8 {{ kind, address }}"))?; return Ok(Route::Peer(target(&value["did"], "pdr_temp_market", label)?, json!(endpoints))); } match value.as_str() { Some(url) if url.starts_with("https://") => { let origin = url.trim_end_matches('/'); let host = &origin["https://".len()..]; let (name, port) = host.split_once(':').unwrap_or((host, "443")); if name.is_empty() || !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-') || port.is_empty() || !port.chars().all(|c| c.is_ascii_digit()) { return Err(format!("{label} must be an https origin or a DID")); } Ok(Route::Service(origin.to_string())) } _ => Ok(Route::Proxy(target(value, "pdr_temp_market", label)?)), } } pub fn optional_route(value: &Value, label: &str) -> Result, String> { match value { Value::Null => Ok(None), value => route(value, label).map(Some), } } /// A strong ref, or its JSON (a form field). pub fn strong(value: &Value, label: &str) -> Result { if let Some(text) = value.as_str() { return strong(&serde_json::from_str(text).map_err(|_| format!("{label} must be a strong ref {{uri, cid}}"))?, label); } strong_ref(value["uri"].as_str(), value["cid"].as_str(), label) } pub fn strong_ref(uri: Option<&str>, cid: Option<&str>, label: &str) -> Result { let uri = uri.filter(|u| u.starts_with("at://did:")); let cid = cid.filter(|c| !c.is_empty()); match (uri, cid) { (Some(uri), Some(cid)) => Ok(TypedStrongRef::new(StrongRef { uri: uri.into(), cid: cid.into() })), _ => Err(format!("{label} must be a strong ref {{uri, cid}}")), } } pub fn at_uri<'a>(value: &'a Value, label: &str) -> Result<&'a str, String> { value.as_str().filter(|u| u.starts_with("at://")).ok_or(format!("{label} must be an at:// URI")) } /// What a command reads on stdin: secrets go here, never in the command (an argument of ssh on the /// host and of the login shell on the guest, so ps shows it on both). pub fn stdin(value: &Value) -> Result, String> { match value { Value::Null => Ok(None), Value::String(s) if s.len() <= 65536 => Ok(Some(s.clone())), _ => Err("stdin must be a string of at most 65536 bytes".into()), } } pub fn tag(value: &Value) -> Result { let tag = value.as_str().filter(|t| !t.is_empty() && t.len() <= 32 && t.bytes().all(|b| b.is_ascii_graphic())); tag.map(str::to_string).ok_or("tag must be 1 to 32 printable ASCII bytes".into()) } pub fn tags(value: &Value) -> Result, String> { match value { Value::Null => Ok(vec!["ssh".into(), "shell".into()]), Value::Array(list) if list.len() <= 6 => list.iter().map(tag).collect(), _ => Err("tags must be a list of at most 6 tags".into()), } } /// A list field: a JSON list, or a string of comma, space or newline separated items. /// Peers to call: a list of https origins and DIDs (list), or of { did, endpoints } (offerings' output). pub fn routes(value: &Value, label: &str) -> Result, String> { match value { Value::Array(peers) if peers.iter().any(Value::is_object) => { if peers.len() > MAX_PEERS { return Err(format!("at most {MAX_PEERS} {label}")); } peers.iter().map(|p| route(p, label)).collect() } value => list(value, label)?.iter().map(|p| route(&json!(p), label)).collect(), } } pub fn list(value: &Value, label: &str) -> Result, String> { let list = match value { Value::Null => return Ok(vec![]), Value::String(text) => &text.split([',', ' ', '\n']).filter(|s| !s.is_empty()).map(|s| json!(s)).collect(), Value::Array(list) => list, _ => return Err(format!("{label} must be a list")), }; if list.len() > MAX_PEERS { return Err(format!("at most {MAX_PEERS} {label}")); } list.iter().map(|v| Ok(v.as_str().ok_or(format!("{label} must be DIDs"))?.to_string())).collect() } pub fn bidders(args: &Args) -> Result, String> { let bidders = list(args.arg("bidders"), "bidders")?.iter().map(|b| did(&json!(b), "bidders")).collect::, _>>()?; if bidders.is_empty() { return Err("bidders is required".into()); } Ok(bidders) } /// Waits re-read on a timer (`poll_ms`) until the budget (`max_polls`) runs out, counted per timer. #[derive(Default, Serialize, Deserialize)] pub struct Polls(BTreeMap); impl Polls { /// Count this wait and set its timer when the budget allows another poll. pub fn again(&mut self, args: &Args, out: &mut Out, timer: CallId) -> bool { let polls = self.0.entry(timer.to_string()).or_default(); *polls += 1; let again = *polls < args.number("max_polls", 40, 200); if again { out.timer(timer, args.number("poll_ms", 3000, 60_000)); } again } }