harnessd 0.1.0

The harness daemon: API server (axum WS + REST), agent runtime host, and CLI (init/pair/doctor).
//! Shared server state and the axum router assembly.

use crate::{rest, ws};
use axum::routing::{get, post};
use axum::Router;
use harness_core::Runtime;
use std::time::Instant;
use tower_http::trace::TraceLayer;

/// Application state shared across all handlers. Cheap to clone.
#[derive(Clone)]
pub struct AppState {
    pub runtime: Runtime,
    pub started: Instant,
    /// Daemon version string reported in the handshake / `/v1/version`.
    pub version: &'static str,
    /// When true, a client must present a valid paired device token (design doc §8).
    /// Default false: trust the loopback/Tailscale boundary until pairing is enabled.
    pub require_pairing: bool,
}

impl AppState {
    pub fn new(runtime: Runtime) -> Self {
        let require_pairing = runtime.config().server.require_pairing;
        AppState {
            runtime,
            started: Instant::now(),
            version: env!("CARGO_PKG_VERSION"),
            require_pairing,
        }
    }

    pub fn uptime_secs(&self) -> u64 {
        self.started.elapsed().as_secs()
    }

    /// Whether a hello with this (optional) device token may connect. With pairing off,
    /// everything on the trusted boundary is accepted; with pairing on, the token's hash
    /// must be a registered device (§8).
    pub fn accepts_token(&self, token: Option<&str>) -> bool {
        if !self.require_pairing {
            return true;
        }
        match token {
            Some(t) => self
                .runtime
                .store()
                .device_hash_exists(&crate::auth::hash(t))
                .unwrap_or(false),
            None => false,
        }
    }
}

/// Build the full router. Versioned under `/v1` so clients and daemon evolve
/// independently (design doc §5).
pub fn router(state: AppState) -> Router {
    Router::new()
        // Liveness / operability (design doc §8, §9).
        .route("/v1/health", get(rest::health))
        .route("/v1/version", get(rest::version))
        // Sessions CRUD + backfill (WS-less path).
        .route(
            "/v1/sessions",
            get(rest::list_sessions).post(rest::create_session),
        )
        .route("/v1/sessions/:id/events", get(rest::session_events))
        // Providers: list (with credential status) + add/update + set-key (§3, TUI setup).
        .route(
            "/v1/providers",
            get(rest::list_providers).post(rest::add_provider),
        )
        .route("/v1/providers/:id/key", post(rest::set_provider_key))
        // Presets: catalog + setup checklist (§7.1–7.2).
        .route("/v1/presets", get(rest::list_presets))
        .route(
            "/v1/presets/:id/requirements",
            get(rest::preset_requirements),
        )
        // Pairing endpoints are scaffolded; they return 501 until §8 lands.
        .route("/v1/pair/start", post(rest::pair_start))
        .route("/v1/pair/complete", post(rest::pair_complete))
        // The live event stream + command channel.
        .route("/v1/ws", get(ws::handler))
        .layer(TraceLayer::new_for_http())
        .with_state(state)
}