haematite-cli 0.21.1

The haematite command: serve a store, its door and Studio, answer MCP, query with jq, and host the store's assistant seat
Documentation
//! The live Lys check a deliberate route asks (PERMISSIONS-ACROSS-THE-STACK D2, D10).
//!
//! The question goes to Lys's `/api/grants/check/batch` through lys-pass's
//! client: every Lys route a service asks is under `/api` on the issuer.
//! Lys answers the batch check only for an administrator or an app, never a
//! seat's pass, so when haematite's app credential is configured the check
//! is asked as the app, about the connection's holder; without it the
//! connection's pass is the caller. The served connection is
//! synchronous, so the check runs to its end on a runtime owned here. Any
//! failure to reach Lys, or an answer outside the permission contract, is
//! returned as an error: the route is then refused "Lys could not be asked",
//! and the pass is never consulted in its place. An act Lys holds for
//! approval is recorded with `POST /drafts`, the same way.

use std::time::Duration;

use lys_pass::deliberate::CheckAnswer;
use lys_pass::drafts::{DraftRequest, RunError, RunReport};
use lys_pass::{Client, Error, KeySet, Target};

use crate::serve::drafts::Drafts;
use crate::serve::pass::{AUDIENCE, LiveCheck};
use crate::serve::storage::Failure;

/// How long a connection to Lys may take to open.
const CONNECT_WITHIN: Duration = Duration::from_secs(5);

/// How long one question to Lys may take to be answered whole: a Lys that
/// accepts and never answers is then named, never waited on for ever.
const ANSWER_WITHIN: Duration = Duration::from_secs(15);

/// Asks the Lys at one issuer origin.
pub struct LysLive {
    client: Client,
    runtime: tokio::runtime::Runtime,
    /// Haematite's Lys app credential, the caller of every check when set.
    asker: Option<String>,
}

impl LysLive {
    /// A live check against the Lys at `issuer`, an HTTP origin.
    ///
    /// # Errors
    ///
    /// Returns [`Error`] when `issuer` is not an HTTP origin, or when the
    /// check's runtime cannot start.
    pub fn new(issuer: &str) -> Result<Self, Error> {
        let mut base = url::Url::parse(issuer)?;
        if !base.path().ends_with('/') {
            base.set_path(&format!("{}/", base.path()));
        }
        let http = reqwest::Client::builder()
            .connect_timeout(CONNECT_WITHIN)
            .timeout(ANSWER_WITHIN)
            .build()
            .map_err(|_| Error::CannotAsk("the live check's client could not start"))?;
        // lys-pass 0.3.4 takes the bare origin and mounts its product routes
        // under `/api` itself; an origin with a path is `lys_origin_required`.
        let client = Client::new(http, base)?;
        let runtime = tokio::runtime::Builder::new_current_thread()
            .enable_all()
            .build()
            .map_err(|_| Error::CannotAsk("the live check's runtime could not start"))?;
        Ok(Self {
            client,
            runtime,
            asker: None,
        })
    }

    /// The same check, asked with haematite's app `credential` as the caller.
    #[must_use]
    pub fn asking_as(mut self, credential: String) -> Self {
        self.asker = Some(credential);
        self
    }

    /// The verification keys Lys publishes now.
    ///
    /// # Errors
    ///
    /// Returns [`Error`] when Lys cannot be asked or its keyset does not
    /// validate whole.
    pub fn keys(&self) -> Result<KeySet, Error> {
        self.runtime.block_on(self.client.fetch_keys())
    }

    /// Pull one page of approved drafts with the connector's pass, after
    /// `after`, apply each exactly once to `drafts` and acknowledge each
    /// outcome to Lys. One explicit pull: nothing here polls or retries.
    ///
    /// # Errors
    ///
    /// Returns where the run stopped. A change applied but not acknowledged
    /// keeps its receipt, and the next run answers it again without applying
    /// anything twice.
    pub fn run_approved(
        &self,
        connector_pass: &str,
        drafts: &mut Drafts<'_>,
        after: Option<&str>,
    ) -> Result<RunReport, Box<RunError<Failure>>> {
        self.runtime
            .block_on(
                self.client
                    .run_approved(connector_pass, AUDIENCE, after, drafts),
            )
            .map_err(Box::new)
    }
}

impl LiveCheck for LysLive {
    fn ask(&self, token: &str, holder: &str, target: &Target) -> Result<CheckAnswer, Error> {
        let answer = self.runtime.block_on(self.client.check_batch(
            self.asker.as_deref().unwrap_or(token),
            holder,
            std::slice::from_ref(target),
            None,
        ))?;
        answer
            .results
            .into_iter()
            .next()
            .ok_or(Error::CannotAsk("Lys answered no result"))
    }

    fn draft(
        &self,
        token: &str,
        request: &DraftRequest,
        judgment: &CheckAnswer,
    ) -> Result<String, Error> {
        let created = self
            .runtime
            .block_on(self.client.create_draft(token, request, judgment))?;
        Ok(created.draft)
    }

    fn run_drafts(
        &self,
        connector: &str,
        drafts: &mut Drafts<'_>,
        after: Option<&str>,
    ) -> Result<RunReport, Box<RunError<Failure>>> {
        self.run_approved(connector, drafts, after)
    }
}

#[cfg(test)]
#[path = "live_tests.rs"]
mod tests;