openlatch-provider 0.2.1

Self-service onboarding CLI + runtime daemon for OpenLatch Editors and Providers
//! Runtime JSON Schema validation. The schema bundle is loaded from
//! `schemas/manifest.schema.json` at first use and reused for the process
//! lifetime. Errors are reported with a JSON-Pointer field path and the
//! offending value (with a `<redacted>` placeholder for any field whose name
//! contains `secret`/`token`/`key`).

use std::sync::OnceLock;

use crate::error::{
    OlError, OL_4210_SCHEMA_MISMATCH, OL_4320_PROVIDER_SCHEMA_INVALID, OL_4321_TOOL_SCHEMA_INVALID,
};

/// Embedded copies of the manifest schemas. These are vendored into the
/// binary via `include_str!` so validation works without filesystem access
/// at runtime — important for `cargo install` users who don't ship the
/// `schemas/` directory.
const MANIFEST_SCHEMA: &str = include_str!("../../schemas/manifest.schema.json");
const MANIFEST_EDITOR: &str = include_str!("../../schemas/manifest-editor.schema.json");
const MANIFEST_TOOL: &str = include_str!("../../schemas/manifest-tool.schema.json");
const MANIFEST_PROVIDER: &str = include_str!("../../schemas/manifest-provider.schema.json");
const MANIFEST_BINDING: &str = include_str!("../../schemas/manifest-binding.schema.json");
const MANIFEST_CAPABILITY: &str = include_str!("../../schemas/manifest-capability.schema.json");
const MANIFEST_PROCESS: &str = include_str!("../../schemas/manifest-process.schema.json");
const MANIFEST_TOOL_V2: &str = include_str!("../../schemas/manifest-tool-v2.schema.json");
const MANIFEST_PROVIDER_V2: &str = include_str!("../../schemas/manifest-provider-v2.schema.json");
const ENUMS: &str = include_str!("../../schemas/enums.schema.json");

/// Compile a Draft-2020-12 validator from the bundled schemas. Cached for
/// the process lifetime so repeated `parse` calls don't re-compile.
fn validator() -> &'static jsonschema::Validator {
    static V: OnceLock<jsonschema::Validator> = OnceLock::new();
    V.get_or_init(|| build_validator().expect("manifest schema must compile"))
}

fn build_validator() -> Result<jsonschema::Validator, jsonschema::ValidationError<'static>> {
    let resources = [
        (
            "https://schemas.openlatch.ai/provider/v1/manifest-editor.schema.json",
            MANIFEST_EDITOR,
        ),
        (
            "https://schemas.openlatch.ai/provider/v1/manifest-tool.schema.json",
            MANIFEST_TOOL,
        ),
        (
            "https://schemas.openlatch.ai/provider/v1/manifest-provider.schema.json",
            MANIFEST_PROVIDER,
        ),
        (
            "https://schemas.openlatch.ai/provider/v1/manifest-binding.schema.json",
            MANIFEST_BINDING,
        ),
        (
            "https://schemas.openlatch.ai/provider/v1/manifest-capability.schema.json",
            MANIFEST_CAPABILITY,
        ),
        (
            "https://schemas.openlatch.ai/provider/v1/manifest-process.schema.json",
            MANIFEST_PROCESS,
        ),
        (
            "https://schemas.openlatch.ai/client/v1/enums.schema.json",
            ENUMS,
        ),
    ];

    // Resolve cross-file refs by serving each related schema through a Registry
    // (jsonschema 0.46 replaced the per-options `with_resource(s)` API).
    let parsed: serde_json::Value =
        serde_json::from_str(MANIFEST_SCHEMA).expect("manifest.schema.json must parse");
    let pairs: Vec<(String, serde_json::Value)> = resources
        .iter()
        .map(|(uri, raw)| {
            let value: serde_json::Value =
                serde_json::from_str(raw).expect("vendored manifest sub-schema must parse");
            ((*uri).to_string(), value)
        })
        .collect();
    let registry = jsonschema::Registry::new()
        .extend(pairs)
        .expect("registry resource URIs must parse")
        .prepare()
        .expect("registry must prepare");

    jsonschema::options()
        .with_registry(&registry)
        .build(&parsed)
}

/// Validate a parsed YAML/JSON value against the bundled schema. Returns
/// the first validation error with its JSON-Pointer path attached.
pub fn validate(value: &serde_json::Value) -> Result<(), OlError> {
    if let Err(err) = validator().validate(value) {
        return Err(OlError::new(
            OL_4210_SCHEMA_MISMATCH,
            format!("schema mismatch at `{}`: {}", err.instance_path(), err),
        ));
    }
    Ok(())
}

fn build_v2_validator(
    root_schema: &str,
) -> Result<jsonschema::Validator, jsonschema::ValidationError<'static>> {
    // v2 schemas use relative `$ref: "manifest-<x>.schema.json"`; relative
    // refs resolve against the v2 root's `$id` (under `…/provider/v2/`). We
    // register each sub-schema under BOTH its canonical v1 URI and a v2
    // sibling alias so the validator can resolve either form.
    let resources = [
        (
            "https://schemas.openlatch.ai/provider/v1/manifest-editor.schema.json",
            MANIFEST_EDITOR,
        ),
        (
            "https://schemas.openlatch.ai/provider/v2/manifest-editor.schema.json",
            MANIFEST_EDITOR,
        ),
        (
            "https://schemas.openlatch.ai/provider/v1/manifest-tool.schema.json",
            MANIFEST_TOOL,
        ),
        (
            "https://schemas.openlatch.ai/provider/v2/manifest-tool.schema.json",
            MANIFEST_TOOL,
        ),
        (
            "https://schemas.openlatch.ai/provider/v1/manifest-provider.schema.json",
            MANIFEST_PROVIDER,
        ),
        (
            "https://schemas.openlatch.ai/provider/v2/manifest-provider.schema.json",
            MANIFEST_PROVIDER,
        ),
        (
            "https://schemas.openlatch.ai/provider/v1/manifest-binding.schema.json",
            MANIFEST_BINDING,
        ),
        (
            "https://schemas.openlatch.ai/provider/v1/manifest-capability.schema.json",
            MANIFEST_CAPABILITY,
        ),
        (
            "https://schemas.openlatch.ai/provider/v2/manifest-capability.schema.json",
            MANIFEST_CAPABILITY,
        ),
        (
            "https://schemas.openlatch.ai/provider/v1/manifest-process.schema.json",
            MANIFEST_PROCESS,
        ),
        (
            "https://schemas.openlatch.ai/client/v1/enums.schema.json",
            ENUMS,
        ),
    ];
    let parsed: serde_json::Value =
        serde_json::from_str(root_schema).expect("v2 schema must parse");
    let pairs: Vec<(String, serde_json::Value)> = resources
        .iter()
        .map(|(uri, raw)| {
            let value: serde_json::Value =
                serde_json::from_str(raw).expect("v1 sub-schema must parse");
            ((*uri).to_string(), value)
        })
        .collect();
    let registry = jsonschema::Registry::new()
        .extend(pairs)
        .expect("registry resource URIs must parse")
        .prepare()
        .expect("registry must prepare");
    jsonschema::options()
        .with_registry(&registry)
        .build(&parsed)
}

fn provider_v2_validator() -> &'static jsonschema::Validator {
    static V: OnceLock<jsonschema::Validator> = OnceLock::new();
    V.get_or_init(|| {
        build_v2_validator(MANIFEST_PROVIDER_V2).expect("provider v2 schema must compile")
    })
}

fn tool_v2_validator() -> &'static jsonschema::Validator {
    static V: OnceLock<jsonschema::Validator> = OnceLock::new();
    V.get_or_init(|| build_v2_validator(MANIFEST_TOOL_V2).expect("tool v2 schema must compile"))
}

/// Validate a parsed value against `schemas/manifest-provider-v2.schema.json`.
pub fn validate_provider_v2(value: &serde_json::Value) -> Result<(), OlError> {
    if let Err(err) = provider_v2_validator().validate(value) {
        return Err(OlError::new(
            OL_4320_PROVIDER_SCHEMA_INVALID,
            format!(
                "provider v2 schema mismatch at `{}`: {}",
                err.instance_path(),
                err
            ),
        ));
    }
    Ok(())
}

/// Validate a parsed value against `schemas/manifest-tool-v2.schema.json`.
pub fn validate_tool_v2(value: &serde_json::Value) -> Result<(), OlError> {
    if let Err(err) = tool_v2_validator().validate(value) {
        return Err(OlError::new(
            OL_4321_TOOL_SCHEMA_INVALID,
            format!(
                "tool v2 schema mismatch at `{}`: {}",
                err.instance_path(),
                err
            ),
        ));
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn empty_object_fails_required_fields() {
        let v = serde_json::json!({});
        let err = validate(&v).unwrap_err();
        assert_eq!(err.code.code, "OL-4210");
    }

    #[test]
    fn minimal_valid_manifest_accepted() {
        let v = serde_json::json!({
            "schema_version": 1,
            "editor": {
                "slug": "acme",
                "display_name": "Acme Security",
                "description": "x"
            }
        });
        validate(&v).unwrap();
    }
}