openlatch-provider 0.1.0

Self-service onboarding CLI + runtime daemon for OpenLatch Editors and Providers
//! Admin control endpoint — `POST /v1/admin/reload`.
//!
//! Binds a separate axum router on `127.0.0.1:<admin_port>` (only) with
//! bearer-auth middleware. The same token also gates future P3.T2b update
//! RPC calls.

use std::net::SocketAddr;
use std::sync::Arc;

use axum::extract::State;
use axum::http::{HeaderMap, StatusCode};
use axum::response::{IntoResponse, Response};
use axum::routing::post;
use axum::Router;
use tracing::{error, info, warn};

use crate::api::client::ApiClient;
use crate::api::editor::list_bindings as api_list_bindings;
use crate::cli::commands::shared;
use crate::error::OlError;
use crate::runtime::admin_token;
use crate::runtime::reload::{ReloadInputs, SharedRoutes};
use crate::runtime::server::RuntimeContext;

#[derive(Clone)]
pub struct AdminState {
    pub ctx: Arc<RuntimeContext>,
    pub admin_token: String,
}

pub fn build_router(state: AdminState) -> Router {
    Router::new()
        .route("/v1/admin/reload", post(handle_reload))
        .with_state(state)
}

/// Bind the admin router on `127.0.0.1:<port>`. Refuses to bind to anything
/// else — the admin endpoint is local-trust-only and the token file's
/// `0600` permissions are the security boundary.
pub async fn serve(
    port: u16,
    state: AdminState,
    shutdown: impl std::future::Future<Output = ()> + Send + 'static,
) -> Result<SocketAddr, OlError> {
    let bind: SocketAddr = ([127, 0, 0, 1], port).into();
    let listener = tokio::net::TcpListener::bind(bind).await.map_err(|e| {
        OlError::new(
            crate::error::OL_4272_XDG_DIR_UNWRITABLE,
            format!("admin bind {bind}: {e}"),
        )
    })?;
    let local = listener.local_addr().map_err(|e| {
        OlError::new(
            crate::error::OL_4272_XDG_DIR_UNWRITABLE,
            format!("admin local_addr: {e}"),
        )
    })?;
    let app = build_router(state);
    info!(addr = %local, "admin endpoint listening");
    if let Err(e) = axum::serve(listener, app)
        .with_graceful_shutdown(shutdown)
        .await
    {
        error!(error = %e, "admin endpoint serve error");
    }
    Ok(local)
}

async fn handle_reload(State(state): State<AdminState>, headers: HeaderMap) -> Response {
    if !verify_bearer(&state.admin_token, &headers) {
        return (StatusCode::UNAUTHORIZED, "missing or invalid bearer token").into_response();
    }
    info!("admin reload requested");

    // Refresh live bindings from platform (best-effort — fall back to cached).
    let live = match shared::make_client().await {
        Ok(client) => match refresh_live(&client).await {
            Ok(rows) => Some(rows),
            Err(e) => {
                warn!(error = %e, "could not refresh live bindings during reload");
                None
            }
        },
        Err(e) => {
            warn!(error = %e, "no API client during reload");
            None
        }
    };

    if let Some(rows) = live {
        let mut guard = state.ctx.live_bindings.lock().await;
        *guard = rows;
    }

    let live_snapshot = state.ctx.live_bindings.lock().await.clone();
    let manifest_known = state.ctx.manifest_secret_ids.lock().await.clone();
    let inputs = ReloadInputs {
        manifest_path: &state.ctx.manifest_path,
        live_bindings: &live_snapshot,
        secret_store: state.ctx.secrets.as_ref(),
        manifest_secret_ids_fallback: Some(manifest_known),
    };

    match crate::runtime::reload::reload_into(&state.ctx.routes, &inputs) {
        Ok(n) => (
            StatusCode::OK,
            axum::Json(serde_json::json!({ "status": "reloaded", "binding_count": n })),
        )
            .into_response(),
        Err(e) => {
            warn!(error = %e, code = %e.code, "admin reload failed; routes unchanged");
            (
                StatusCode::CONFLICT,
                axum::Json(serde_json::json!({
                    "status": "reload_failed",
                    "error": { "code": e.code.code, "message": e.message }
                })),
            )
                .into_response()
        }
    }
}

fn verify_bearer(expected: &str, headers: &HeaderMap) -> bool {
    let Some(auth) = headers.get("authorization").and_then(|v| v.to_str().ok()) else {
        return false;
    };
    let Some(presented) = auth.strip_prefix("Bearer ") else {
        return false;
    };
    admin_token::matches(expected, presented)
}

async fn refresh_live(
    client: &ApiClient,
) -> Result<Vec<crate::api::editor::EditorBindingRow>, OlError> {
    api_list_bindings(client).await
}

/// Convenience plumbing: expose [`SharedRoutes`] swap helper to outside
/// callers without leaking implementation details.
pub fn swap_routes(shared: &SharedRoutes, table: crate::runtime::multi_tool::RouteTable) {
    shared.store(table);
}

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

    #[test]
    fn verify_bearer_matches_only_with_correct_prefix_and_value() {
        let mut h = HeaderMap::new();
        h.insert("authorization", "Bearer secret".parse().unwrap());
        assert!(verify_bearer("secret", &h));
        assert!(!verify_bearer("other", &h));

        let mut bad = HeaderMap::new();
        bad.insert("authorization", "Token secret".parse().unwrap());
        assert!(!verify_bearer("secret", &bad));
    }

    #[test]
    fn verify_bearer_returns_false_when_header_absent() {
        let h = HeaderMap::new();
        assert!(!verify_bearer("anything", &h));
    }
}