haematite-cli 0.21.7

The haematite command: serve a store, its door and Studio, answer MCP, query with jq, and host the store's assistant seat
Documentation
//! Verify the signed store authority and carry the person's bounded Lys acts.
use crate::serve::storage::Failure;
use lys_pass::{KeySet, VerifiedPass};
use serde_json::{Value, json};
use std::time::Duration;

// The existing door body ceiling bounds each issuer reply.
const RESPONSE_BYTES: usize = 1_048_576;
// Sixty-four pages cap the work and cursor memory of one interactive draft lookup.
const DRAFT_PAGES: usize = 64;
async fn deadline<T>(
    within: Duration,
    work: impl std::future::Future<Output = Result<T, Failure>>,
) -> Result<T, Failure> {
    tokio::time::timeout(within,work).await.map_err(|_|Failure::new("lys_draft_deadline","Lys did not return a receipt within the configured sign-in request deadline; check the draft in Lys"))?
}
pub(super) struct Client {
    pub issuer: String,
    pub http: reqwest::Client,
}
impl Client {
    pub async fn identity(
        &self,
        agent: &str,
        pass: &str,
        store: &str,
        now: u64,
    ) -> Result<String, Failure> {
        let client = lys_pass::Client::new(
            self.http.clone(),
            url::Url::parse(&self.issuer).map_err(|_| unavailable())?,
        )
        .map_err(|error| named(&error))?;
        let keys = client.fetch_keys().await.map_err(|error| named(&error))?;
        identity(&keys, &self.issuer, agent, pass, store, now)
    }
    async fn read(&self, request: reqwest::RequestBuilder) -> Result<Value, Failure> {
        let mut response = request.send().await.map_err(|_| unavailable())?;
        let status = response.status();
        let mut bytes = Vec::new();
        while let Some(chunk) = response.chunk().await.map_err(|_| unavailable())? {
            if chunk.len() > RESPONSE_BYTES.saturating_sub(bytes.len()) {
                return Err(Failure::new(
                    "lys_response_too_large",
                    format!(
                        "lys_response_bytes={RESPONSE_BYTES}; count={}",
                        bytes.len().saturating_add(chunk.len())
                    ),
                ));
            }
            bytes.extend_from_slice(&chunk);
        }
        let value: Value = serde_json::from_slice(&bytes).map_err(|_| unavailable())?;
        if !status.is_success() {
            let code = value["refusal"]
                .as_str()
                .or_else(|| value["code"].as_str())
                .filter(|code| {
                    !code.is_empty()
                        && code.len() <= 128
                        && code.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_')
                })
                .ok_or_else(unavailable)?;
            let words = value["reason"]
                .as_str()
                .or_else(|| value["message"].as_str())
                .filter(|words| !words.is_empty())
                .ok_or_else(unavailable)?;
            return Err(Failure::new(code, words));
        }
        Ok(value)
    }
    pub async fn draft(&self, pass: &str, id: &str, within: Duration) -> Result<Value, Failure> {
        deadline(within, self.draft_pages(pass, id)).await
    }
    async fn draft_pages(&self, pass: &str, id: &str) -> Result<Value, Failure> {
        let mut after: Option<String> = None;
        for _ in 0..DRAFT_PAGES {
            let mut url = url::Url::parse(&format!(
                "{}/api/product-drafts",
                self.issuer.trim_end_matches('/')
            ))
            .map_err(|_| unavailable())?;
            url.query_pairs_mut().append_pair("limit", "64");
            if let Some(after) = &after {
                url.query_pairs_mut().append_pair("after", after);
            }
            let page = self.read(self.http.get(url).bearer_auth(pass)).await?;
            let drafts = page["drafts"]
                .as_array()
                .filter(|drafts| drafts.len() <= 64)
                .ok_or_else(unavailable)?;
            if let Some(held) = drafts.iter().find(|held| held["id"] == id) {
                let digest = held["request_digest"].as_str().ok_or_else(unavailable)?;
                if digest.len() != 64
                    || !digest.bytes().all(|b| b.is_ascii_hexdigit())
                    || !held["app"].is_string()
                    || !held["holder"].is_string()
                    || !held["responsible"].is_string()
                    || !held["target"]["kind"].is_string()
                    || !held["target"]["id"].is_string()
                    || !held["target"]["action"].is_string()
                {
                    return Err(unavailable());
                }
                return Ok(held.clone());
            }
            let next = page.get("next").ok_or_else(unavailable)?;
            if next.is_null() {
                return Err(Failure::new(
                    "draft_not_found",
                    "Lys returned no visible draft by that id",
                ));
            }
            let next = next
                .as_str()
                .filter(|next| !next.is_empty() && after.as_deref() != Some(next))
                .ok_or_else(unavailable)?;
            after = Some(next.to_owned());
        }
        Err(Failure::new(
            "lys_draft_pages_exceeded",
            format!("lys_draft_pages={DRAFT_PAGES}; use the draft's Lys link to find it"),
        ))
    }
    pub async fn decide_held(
        &self,
        pass: &str,
        held: &Value,
        decision: &str,
        reason: Option<&str>,
        within: Duration,
    ) -> Result<Value, Failure> {
        deadline(within, self.post_decision(pass, held, decision, reason)).await
    }
    async fn post_decision(
        &self,
        pass: &str,
        held: &Value,
        decision: &str,
        reason: Option<&str>,
    ) -> Result<Value, Failure> {
        let id = held["id"].as_str().ok_or_else(unavailable)?;
        let digest = held["request_digest"].as_str().ok_or_else(unavailable)?;
        let random = super::session::session_id()?;
        let operation = format!("op-{}", random.get(..32).ok_or_else(unavailable)?);
        let mut body = json!({"operation":operation,"request_digest":digest});
        if decision == "refuse" {
            body["reason"] = json!(reason.ok_or_else(|| Failure::new(
                "assistant_decision_invalid",
                "a refusal requires the person's own reason"
            ))?);
        }
        let mut url = url::Url::parse(&format!(
            "{}/api/product-drafts",
            self.issuer.trim_end_matches('/')
        ))
        .map_err(|_| unavailable())?;
        url.path_segments_mut()
            .map_err(|()| unavailable())?
            .push(id)
            .push(decision);
        let answer = self
            .read(self.http.post(url).bearer_auth(pass).json(&body))
            .await?;
        if answer["draft"] != id
            || !matches!(
                answer["state"].as_str(),
                Some("waiting" | "approved" | "refused")
            )
        {
            return Err(unavailable());
        }
        Ok(answer)
    }
    #[cfg(test)]
    pub async fn decide(
        &self,
        pass: &str,
        id: &str,
        decision: &str,
        reason: Option<&str>,
    ) -> Result<Value, Failure> {
        let within = Duration::from_millis(super::config::SignInBounds::default().total_ms.get());
        let started = std::time::Instant::now();
        let held = self.draft(pass, id, within).await?;
        self.decide_held(
            pass,
            &held,
            decision,
            reason,
            within.saturating_sub(started.elapsed()),
        )
        .await
    }
}
pub(super) fn identity(
    keys: &KeySet,
    issuer: &str,
    agent: &str,
    pass: &str,
    store: &str,
    now: u64,
) -> Result<String, Failure> {
    let verified = VerifiedPass::verify(pass, keys, issuer, "haematite", now)
        .map_err(|error| named(&error))?;
    let holder = &verified.claims().holder;
    if holder.kind != "agent" || holder.id != agent {
        return Err(Failure::new(
            "assistant_agent_mismatch",
            "the pass holder is not the named agent",
        ));
    }
    if matches!(
        verified
            .evaluate("haematite.database", store, "agent_permission", now)
            .map_err(|error| named(&error))?,
        lys_pass::Decision::Refused
    ) {
        return Err(Failure::new(
            "seat_not_yours",
            "the signed assistant profile grant does not name this store",
        ));
    }
    holder
        .responsible
        .as_ref()
        .filter(|person| super::assistant_wire::person(person))
        .cloned()
        .ok_or_else(|| {
            Failure::new(
                "assistant_responsible_missing",
                "the agent pass must name its responsible person",
            )
        })
}
fn named(error: &lys_pass::Error) -> Failure {
    Failure::new(error.name(), "Lys refused the assistant's identity")
}
fn unavailable() -> Failure {
    Failure::new(
        "lys_unavailable",
        "Lys could not be reached or did not return a complete receipt; check the draft in Lys",
    )
}

#[cfg(test)]
#[path = "assistant_lys_deadline_tests.rs"]
mod deadline_tests;