i-self 0.4.3

Personal developer-companion CLI: scans your repos, indexes code semantically, watches your activity, and moves AI-agent sessions between tools (Claude Code, Aider, Goose, OpenAI Codex CLI, Continue.dev, OpenCode).
use axum::{
    middleware,
    routing::{get, post},
    Router,
};
use std::sync::Arc;
use tokio::sync::RwLock;
use tower_governor::{governor::GovernorConfigBuilder, GovernorLayer};
use tower_http::trace::{DefaultMakeSpan, DefaultOnResponse, TraceLayer};

use super::{auth::AuthConfig, handlers::*, AppState};

pub fn create_router(
    state: Arc<RwLock<AppState>>,
    auth: Arc<AuthConfig>,
) -> Router {
    // Per-IP rate limit specifically for /api/ai/*. Bearer auth gates *who*
    // can call; this caps *how often* so a leaked token doesn't translate
    // directly to unbounded LLM spend. Defaults: 6 requests + burst 12 per
    // minute per peer IP. Tunable via env so a single user with a heavy
    // burst pattern can opt out.
    let ai_governor = GovernorConfigBuilder::default()
        .per_second(
            std::env::var("ISELF_AI_RATE_PER_SECOND")
                .ok()
                .and_then(|v| v.parse().ok())
                .unwrap_or(10),
        )
        .burst_size(
            std::env::var("ISELF_AI_RATE_BURST")
                .ok()
                .and_then(|v| v.parse().ok())
                .unwrap_or(12),
        )
        .finish()
        .expect("hardcoded governor config; should never fail");
    let ai_governor = Arc::new(ai_governor);

    // AI subgroup — gets BOTH bearer auth and rate limiting.
    let ai_routes = Router::new()
        .route("/api/ai/ask", post(ai_ask))
        .route("/api/ai/explain", post(ai_explain))
        .route("/api/ai/generate", post(ai_generate))
        .layer(GovernorLayer { config: ai_governor });

    // Routes that require auth when a token is configured.
    let protected = Router::new()
        // Dashboard HTML — gated because it embeds the developer profile and
        // links to gated APIs. Bootstrap via `?token=...` query string on the
        // first navigation; the JS strips it from the URL immediately.
        .route("/", get(dashboard_handler))
        .route("/api/profile", get(get_profile))
        .route("/api/stats", get(get_stats))
        // Semantic Search
        .route("/api/search", post(semantic_search))
        .route("/api/search/stats", get(search_stats))
        // Team
        .route("/api/teams", get(list_teams))
        .route("/api/teams/:name", get(get_team))
        .route("/api/teams/:name/aggregate", post(aggregate_team))
        // AI Assistant — the high-cost endpoints; auth + rate limit.
        .merge(ai_routes)
        .route_layer(middleware::from_fn_with_state(
            auth.clone(),
            super::auth::require_bearer,
        ))
        .with_state(state.clone());

    // Always-public routes.
    //
    // /static/* is public because browsers don't propagate the URL token (or
    // Authorization headers) to <script src> / <link href> loads. If static
    // assets were gated, the dashboard HTML would render with broken styles
    // and no JS, which is worse than the marginal info leak from CSS/JS being
    // readable. The actual data lives behind /api/* which stays gated.
    Router::new()
        .route("/healthz", get(healthz))
        .route("/static/*path", get(static_handler))
        .with_state(state)
        .merge(protected)
        // Structured request logging — emits one tracing span per request
        // with method, URI, status, and duration. Tokens in the
        // `Authorization` header don't appear in span fields by default.
        // Note: a `?token=` query string DOES appear; that's a known cost
        // of supporting query-string bootstrap auth, mitigated because the
        // dashboard JS strips the token from the URL on first load.
        .layer(
            TraceLayer::new_for_http()
                .make_span_with(DefaultMakeSpan::new().level(tracing::Level::INFO))
                .on_response(DefaultOnResponse::new().level(tracing::Level::INFO)),
        )
}

async fn healthz() -> &'static str {
    "ok"
}