~ knotless Search/
↑↓ move↵ open⇧↵ all results

johnandersen777.bsky.social/atlibs-socialweb-computer-requester

socialweb.computer market requester and policy modules for atlibs, with its web pages and lexicons

0
0
main
222 lines · 8.7 KB Raw History
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<S> = abi::Step<Args, S, CallId>;
pub type Out = abi::Out<CallId>;

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<String, Value>")]
pub struct Args {
    fields: Map<String, Value>,
}

impl TryFrom<Map<String, Value>> for Args {
    type Error = String;

    fn try_from(fields: Map<String, Value>) -> Result<Self, String> {
        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, String> {
        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<String, String> {
    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<String, String> {
    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<Value> {
    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<Route, String> {
    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<Option<Route>, 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<TypedStrongRef, String> {
    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<TypedStrongRef, String> {
    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<Option<String>, 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<String, String> {
    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<Vec<String>, 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<Vec<Route>, 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<Vec<String>, 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<Vec<String>, String> {
    let bidders = list(args.arg("bidders"), "bidders")?.iter().map(|b| did(&json!(b), "bidders")).collect::<Result<Vec<_>, _>>()?;
    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<String, u64>);

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
    }
}