haematite-cli 0.21.3

The haematite command: serve a store, its door and Studio, answer MCP, query with jq, and host the store's assistant seat
Documentation
//! `haematite lys-schema`: haematite's Lys schema registered as the file says,
//! and drift refused (ACCESS-001 R1).
//!
//! The schema is docs/design/access/schema.json, compiled in, so the binary a
//! deploy installs registers exactly the schema its routes were checked
//! against. `--check` reads the version Lys holds and fails when it differs
//! from the file; `--register` makes the file the next version, naming the
//! version it replaces, under an operation id derived from the file, so a
//! retried registration finds the change it made. The app credential is
//! read from the file the command names and sent only as the bearer of the
//! two requests; it is never printed.

use std::io::Read;
use std::path::{Path, PathBuf};

use haematite::tree::Hash;
use serde_json::{Value, json};

/// The schema every haematite route row is checked against.
pub const SCHEMA: &str = include_str!(concat!(
    env!("CARGO_MANIFEST_DIR"),
    "/embedded/access/schema.json"
));

const APP: &str = "haematite";
const CREDENTIAL_BYTES: u64 = 4096;
const USAGE: &str = "usage: haematite lys-schema --issuer <origin> --credential-file <absolute path> \
                     (--check | --register | --record <file>)\n       haematite lys-schema --recorded <file>";

/// What the command was asked to do.
#[derive(Debug, Clone, PartialEq, Eq)]
enum Mode {
    Check,
    Register,
    /// Write the version Lys holds, as it answered, to the file.
    Record(PathBuf),
}

/// Run `haematite lys-schema`.
///
/// # Errors
///
/// The usage; `schema_drift` when the registered version differs and only a
/// check was asked; or Lys's refusal or unreachability, by name.
pub fn run(args: impl Iterator<Item = String>) -> Result<(), String> {
    let mut args = args.peekable();
    if args.peek().map(String::as_str) == Some("--recorded") {
        args.next();
        let path = args.next().ok_or(USAGE)?;
        if args.next().is_some() {
            return Err(USAGE.to_owned());
        }
        return check_recorded(Path::new(&path));
    }
    let (issuer, credential, mode) = parse(args)?;
    let schema: Value = serde_json::from_str(SCHEMA)
        .map_err(|error| format!("schema.json is not JSON: {error}"))?;
    let bearer = read_credential(&credential)?;
    let address = url::Url::parse(&issuer)
        .and_then(|origin| origin.join(&format!("api/apps/{APP}/schema")))
        .map_err(|_| format!("the issuer {issuer} is not an address"))?;
    let runtime = tokio::runtime::Builder::new_current_thread()
        .enable_all()
        .build()
        .map_err(|error| format!("the schema check's runtime could not start: {error}"))?;
    runtime.block_on(async {
        let bounds = crate::door::config::SignInBounds::from_env()?;
        let http = schema_client(std::time::Duration::from_millis(bounds.total_ms.get()))?;
        let (version, registered) = registered(&http, &address, &bearer).await?;
        if let Mode::Record(path) = &mode {
            let recorded = json!({ "version": version, "schema": registered });
            let text = serde_json::to_string_pretty(&recorded)
                .map_err(|error| format!("the recorded schema could not be written: {error}"))?;
            std::fs::write(path, format!("{text}\n")).map_err(|error| {
                format!(
                    "the recorded schema {} could not be written: {error}",
                    path.display()
                )
            })?;
            eprintln!(
                "recorded: {APP} schema version {version} in {}",
                path.display()
            );
            return Ok(());
        }
        if canonical(&registered) == canonical(&schema) {
            eprintln!("registered: {APP} schema version {version} is schema.json");
            return Ok(());
        }
        if mode == Mode::Check {
            return Err(format!(
                "schema_drift: {APP} schema version {version} at {issuer} differs from \
                 docs/design/access/schema.json; register it with --register"
            ));
        }
        let body = json!({
            "operation": operation_id(),
            "replaces": version,
            "schema": schema,
        });
        let answer = send(http.put(address.clone()).bearer_auth(&bearer), &body).await?;
        let applied = answer["applied"].as_bool().unwrap_or(false);
        if applied {
            eprintln!("registered: {APP} schema.json is version {}", version + 1);
        } else {
            eprintln!(
                "proposed: {APP} schema.json waits for an administrator on Lys's Apps screen, \
                 replacing version {version}"
            );
        }
        Ok(())
    })
}

/// The gate's check, with no network and no credential: the file is Lys's
/// answer as `--record` wrote it, and it must hold schema.json. A gate leg
/// never depends on a live service at its venue (Tom; 10 Oct 2026 row).
fn check_recorded(path: &Path) -> Result<(), String> {
    let schema: Value = serde_json::from_str(SCHEMA)
        .map_err(|error| format!("schema.json is not JSON: {error}"))?;
    let text = std::fs::read_to_string(path).map_err(|error| {
        format!(
            "the recorded schema {} could not be read: {error}",
            path.display()
        )
    })?;
    let recorded: Value = serde_json::from_str(&text).map_err(|error| {
        format!(
            "the recorded schema {} is not JSON: {error}",
            path.display()
        )
    })?;
    let version = recorded["version"]
        .as_u64()
        .ok_or_else(|| format!("the recorded schema {} names no version", path.display()))?;
    if canonical(&recorded["schema"]) == canonical(&schema) {
        eprintln!("recorded: {APP} schema version {version} is schema.json");
        return Ok(());
    }
    Err(format!(
        "schema_drift: the recorded {APP} schema version {version} ({}) differs from \
         docs/design/access/schema.json; register it, then record it with --record",
        path.display()
    ))
}

fn parse(mut args: impl Iterator<Item = String>) -> Result<(String, PathBuf, Mode), String> {
    let (mut issuer, mut credential, mut mode) = (None, None, None);
    while let Some(arg) = args.next() {
        match arg.as_str() {
            "--issuer" if issuer.is_none() => issuer = Some(args.next().ok_or(USAGE)?),
            "--credential-file" if credential.is_none() => {
                credential = Some(PathBuf::from(args.next().ok_or(USAGE)?));
            }
            "--check" if mode.is_none() => mode = Some(Mode::Check),
            "--register" if mode.is_none() => mode = Some(Mode::Register),
            "--record" if mode.is_none() => {
                mode = Some(Mode::Record(PathBuf::from(args.next().ok_or(USAGE)?)));
            }
            _ => return Err(format!("unknown or repeated argument {arg:?}\n{USAGE}")),
        }
    }
    let credential = credential.ok_or(USAGE)?;
    if !credential.is_absolute() {
        return Err(USAGE.to_owned());
    }
    Ok((issuer.ok_or(USAGE)?, credential, mode.ok_or(USAGE)?))
}

/// The app credential in `path`, `lys-app.haematite.<secret>`, as its one
/// line; named by its path only when it cannot be read or is not one line.
pub(in crate::serve) fn read_credential(path: &Path) -> Result<String, String> {
    let unreadable = || format!("the credential file {} could not be read", path.display());
    let file = std::fs::File::open(path).map_err(|_| unreadable())?;
    let mut text = String::new();
    file.take(CREDENTIAL_BYTES + 1)
        .read_to_string(&mut text)
        .map_err(|_| unreadable())?;
    let line = text.strip_suffix('\n').unwrap_or(&text);
    if u64::try_from(text.len()).unwrap_or(u64::MAX) > CREDENTIAL_BYTES
        || !line.starts_with("lys-app.haematite.")
        || line.chars().any(char::is_control)
    {
        return Err(format!(
            "the credential file {} is not one line of haematite's Lys app credential",
            path.display()
        ));
    }
    Ok(line.to_owned())
}

/// The version Lys holds and its schema.
async fn registered(
    http: &reqwest::Client,
    address: &url::Url,
    bearer: &str,
) -> Result<(u64, Value), String> {
    let answer = send(http.get(address.clone()).bearer_auth(bearer), &Value::Null).await?;
    let version = answer["version"]
        .as_u64()
        .ok_or("lys_could_not_be_asked: the schema answer names no version")?;
    Ok((version, answer["schema"].clone()))
}

/// A schema as Lys reads it: every list of names is a set, so it is
/// compared sorted, and an empty member (`relations: {}`, `parents: []`) is
/// the same as one not given, since Lys fills them in when it stores a
/// schema.
fn canonical(value: &Value) -> Value {
    match value {
        Value::Object(members) => Value::Object(
            members
                .iter()
                .map(|(name, member)| (name.clone(), canonical(member)))
                .filter(|(_, member)| !is_empty(member))
                .collect(),
        ),
        Value::Array(items) => {
            let mut items: Vec<Value> = items.iter().map(canonical).collect();
            if items.iter().all(Value::is_string) {
                items.sort_by(|left, right| left.as_str().cmp(&right.as_str()));
            }
            Value::Array(items)
        }
        other => other.clone(),
    }
}

fn is_empty(value: &Value) -> bool {
    match value {
        Value::Object(members) => members.is_empty(),
        Value::Array(items) => items.is_empty(),
        _ => false,
    }
}

/// One request's JSON answer, or Lys's refusal by its name. A null `body`
/// sends none.
async fn send(request: reqwest::RequestBuilder, body: &Value) -> Result<Value, String> {
    let request = if body.is_null() {
        request
    } else {
        request
            .header("content-type", "application/json")
            .body(body.to_string())
    };
    let response = request
        .send()
        .await
        .map_err(|_| "lys_could_not_be_asked: Lys could not be reached".to_owned())?;
    let status = response.status();
    let text = response
        .text()
        .await
        .map_err(|_| "lys_could_not_be_asked: Lys's answer could not be read".to_owned())?;
    let answer: Value = serde_json::from_str(&text)
        .map_err(|_| format!("lys_could_not_be_asked: Lys answered {status} without JSON"))?;
    if !status.is_success() {
        let name = answer["refusal"]
            .as_str()
            .or_else(|| answer["error"].as_str())
            .unwrap_or("lys_refused");
        return Err(format!("{name}: Lys refused the schema request ({status})"));
    }
    Ok(answer)
}

/// `op-` and 32 hex digits of the file's hash: the same file is the same
/// change, so a retried registration is found, not made twice.
fn operation_id() -> String {
    let digest = Hash::of(SCHEMA.as_bytes()).to_string();
    format!("op-{}", digest.get(..32).unwrap_or(&digest))
}

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

fn schema_client(timeout: std::time::Duration) -> Result<reqwest::Client, String> {
    let bounds = crate::door::config::SignInBounds::from_env()?;
    reqwest::Client::builder()
        .connect_timeout(std::time::Duration::from_millis(bounds.connect_ms.get()).min(timeout))
        .timeout(timeout)
        .build()
        .map_err(|_| "lys_could_not_be_asked: the schema HTTP client could not start".to_owned())
}

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