openlatch-provider 0.2.2

Self-service onboarding CLI + runtime daemon for OpenLatch Editors and Providers
//! Binding-id → localhost route resolution.
//!
//! At startup the listener crosswalks three sources to build the routing
//! table:
//!
//! 1. The on-disk manifest (`<slug>.yaml`): tells us which `(tool, provider)`
//!    pairs the editor publishes, plus the *local* HTTP target the runtime
//!    should proxy to (`bindings[].local_endpoint`, optional).
//! 2. The platform's view of `/api/v1/editor/bindings`: assigns each pair an
//!    `id` (`bnd_…`). The platform routes inbound by binding id in
//!    `X-OpenLatch-Binding-Id`, so we must know the mapping.
//! 3. The local [`BindingSecretStore`]: holds the `whsec_<base64>` HMAC key
//!    that the platform issued at `register` / `bindings rotate-secret`. No
//!    secret = we cannot verify signatures = OL-4222.
//!
//! Anything missing on either side is an OL-4222 and aborts startup — the
//! runtime never starts in a half-configured state where some bindings are
//! authenticatable and others silently fail.

use std::collections::{BTreeMap, HashMap};

use crate::api::editor::EditorBindingRow;
use crate::error::{OlError, OL_4222_BINDING_NOT_CONFIGURED};
use crate::generated::Manifest;

/// Default localhost route used when a manifest binding has no
/// `local_endpoint` override. Documented on the user-facing manifest schema
/// guide.
pub const DEFAULT_LOCAL_ENDPOINT: &str = "http://127.0.0.1:8080/event";

/// One binding's resolved row in the runtime routing table.
#[derive(Debug, Clone)]
pub struct LocalRoute {
    pub binding_id: String,
    pub tool_slug: String,
    pub provider_slug: String,
    /// HTTP URL of the vendor's tool server (typically `http://127.0.0.1:NNNN`).
    pub local_url: String,
}

/// Hash-keyed snapshot built once per reload.
#[derive(Debug, Clone, Default)]
pub struct RouteTable {
    by_binding_id: HashMap<String, LocalRoute>,
}

impl RouteTable {
    pub fn empty() -> Self {
        Self::default()
    }

    pub fn lookup(&self, binding_id: &str) -> Option<&LocalRoute> {
        self.by_binding_id.get(binding_id)
    }

    pub fn len(&self) -> usize {
        self.by_binding_id.len()
    }

    pub fn is_empty(&self) -> bool {
        self.by_binding_id.is_empty()
    }

    pub fn binding_ids(&self) -> impl Iterator<Item = &String> {
        self.by_binding_id.keys()
    }

    pub fn routes(&self) -> impl Iterator<Item = &LocalRoute> {
        self.by_binding_id.values()
    }
}

/// Resolve the runtime's routing table from manifest + live platform state +
/// local secrets. Fails as soon as any binding can't be authenticated.
pub fn resolve_routes(
    manifest: &Manifest,
    live_bindings: &[EditorBindingRow],
    known_secret_ids: &[String],
) -> Result<RouteTable, OlError> {
    let known_secrets: std::collections::HashSet<&str> =
        known_secret_ids.iter().map(String::as_str).collect();

    // The manifest's `bindings` field is `Vec<ManifestBinding>` in the typify
    // shape — keyed by (tool, provider) slug. We re-key by `(tool, provider)`
    // to look up against the platform's view.
    let mut by_pair: BTreeMap<(String, String), &EditorBindingRow> = BTreeMap::new();
    for row in live_bindings {
        by_pair.insert((row.tool.clone(), row.provider.clone()), row);
    }

    let mut table = HashMap::new();
    let mut missing_ids: Vec<String> = Vec::new();

    for binding in manifest.bindings.iter() {
        let tool_slug = binding.tool.clone();
        let provider_slug = binding.provider.clone();
        let pair_key = (tool_slug.clone(), provider_slug.clone());

        let Some(live) = by_pair.get(&pair_key) else {
            return Err(OlError::new(
                OL_4222_BINDING_NOT_CONFIGURED,
                format!(
                    "manifest binding (tool=`{}`, provider=`{}`) is not registered with the \
                     platform — run `openlatch-provider register` first",
                    tool_slug, provider_slug,
                ),
            ));
        };

        if !known_secrets.contains(live.id.as_str()) {
            missing_ids.push(live.id.clone());
            continue;
        }

        let local_url = local_endpoint_for(binding);
        table.insert(
            live.id.clone(),
            LocalRoute {
                binding_id: live.id.clone(),
                tool_slug: tool_slug.clone(),
                provider_slug: provider_slug.clone(),
                local_url,
            },
        );
    }

    if !missing_ids.is_empty() {
        let joined = missing_ids.join(", ");
        return Err(OlError::new(
            OL_4222_BINDING_NOT_CONFIGURED,
            format!(
                "no local secret available for binding(s) [{joined}] — \
                 the runtime cannot verify inbound webhook signatures"
            ),
        )
        .with_suggestion(
            "Re-issue the secret with `openlatch-provider bindings rotate-secret <id>`. \
             The platform-side secret rotates atomically and the new plaintext is \
             revealed to you exactly once.",
        ));
    }

    Ok(RouteTable {
        by_binding_id: table,
    })
}

fn local_endpoint_for(binding: &crate::generated::ManifestBinding) -> String {
    binding
        .local_endpoint
        .clone()
        .filter(|s| !s.is_empty())
        .unwrap_or_else(|| DEFAULT_LOCAL_ENDPOINT.to_string())
}

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

    fn manifest_with_one_binding() -> Manifest {
        let yaml = r#"
schema_version: 1
editor:
  slug: my-editor
  display_name: My Editor
  description: One-binding test fixture used to exercise resolve_routes.
tools:
  - slug: pii-fast
    version: 1.0.0
    license: apache-2.0
    description: Fixture tool for the runtime route-resolver tests.
    hooks_supported: [pre_tool_use]
    agents_supported: [claude-code]
    capabilities:
      - threat_category: pii_outbound
        execution_mode: sync
        declared_latency_p95_ms: 80
        needs_raw_payload: false
providers:
  - slug: example-prv-us
    display_name: Example US
    region: us-east-1
    total_capacity_qps: 500
    endpoint_url: https://provider-us.example.io/v1/event
bindings:
  - tool: pii-fast
    provider: example-prv-us
    local_endpoint: http://127.0.0.1:8081/event
    declared_latency_p95_ms: 80
    capacity_qps: 500
    priority: 100
    pricing:
      unit: per_event
      amount_usd: 0.0002
    process:
      command: ["./pii-fast"]
      health_check:
        http:
          port: 8081
"#;
        crate::manifest::parse(yaml.as_bytes()).expect("fixture parses")
    }

    fn live_row(id: &str, tool: &str, provider: &str) -> EditorBindingRow {
        EditorBindingRow {
            id: id.into(),
            tool: tool.into(),
            provider: provider.into(),
            state: Some("active".into()),
            routing_score: None,
        }
    }

    #[test]
    fn resolve_routes_happy_path() {
        let m = manifest_with_one_binding();
        let live = vec![live_row("bnd_42", "pii-fast", "example-prv-us")];
        let secrets = vec!["bnd_42".to_string()];
        let table = resolve_routes(&m, &live, &secrets).unwrap();
        assert_eq!(table.len(), 1);
        let route = table.lookup("bnd_42").expect("present");
        assert_eq!(route.tool_slug, "pii-fast");
        assert_eq!(route.provider_slug, "example-prv-us");
        assert_eq!(route.local_url, "http://127.0.0.1:8081/event");
    }

    #[test]
    fn resolve_routes_uses_default_local_endpoint() {
        let yaml_no_override = r#"
schema_version: 1
editor:
  slug: my-editor
  display_name: My Editor
  description: Fixture exercising the local_endpoint default fallback.
tools:
  - slug: pii-fast
    version: 1.0.0
    license: apache-2.0
    description: Fixture tool for the runtime route-resolver tests.
    hooks_supported: [pre_tool_use]
    agents_supported: [claude-code]
    capabilities:
      - threat_category: pii_outbound
        execution_mode: sync
        declared_latency_p95_ms: 80
        needs_raw_payload: false
providers:
  - slug: example-prv-us
    display_name: Example US
    region: us-east-1
    total_capacity_qps: 500
    endpoint_url: https://provider-us.example.io/v1/event
bindings:
  - tool: pii-fast
    provider: example-prv-us
    declared_latency_p95_ms: 80
    capacity_qps: 500
    priority: 100
    pricing:
      unit: per_event
      amount_usd: 0.0002
    process:
      command: ["./pii-fast"]
      health_check:
        http:
          port: 8081
"#;
        let m = crate::manifest::parse(yaml_no_override.as_bytes()).unwrap();
        let live = vec![live_row("bnd_42", "pii-fast", "example-prv-us")];
        let secrets = vec!["bnd_42".to_string()];
        let table = resolve_routes(&m, &live, &secrets).unwrap();
        let route = table.lookup("bnd_42").unwrap();
        assert_eq!(route.local_url, DEFAULT_LOCAL_ENDPOINT);
    }

    #[test]
    fn resolve_routes_4222_when_platform_doesnt_know_pair() {
        let m = manifest_with_one_binding();
        let live = vec![]; // platform knows nothing
        let err = resolve_routes(&m, &live, &[]).unwrap_err();
        assert_eq!(err.code.code, "OL-4222");
    }

    #[test]
    fn resolve_routes_4222_when_secret_missing_locally() {
        let m = manifest_with_one_binding();
        let live = vec![live_row("bnd_42", "pii-fast", "example-prv-us")];
        let err = resolve_routes(&m, &live, &[]).unwrap_err();
        assert_eq!(err.code.code, "OL-4222");
    }
}