1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
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"
}