Skip to main content

roboticus_api/api/routes/
mod.rs

1mod admin;
2mod agent;
3mod channels;
4mod cron;
5mod health;
6mod interview;
7mod memory;
8mod sessions;
9mod skills;
10pub(crate) mod subagent_integrity;
11pub(crate) use self::agent::execute_scheduled_agent_task;
12mod subagents;
13
14use std::collections::HashMap;
15use std::path::PathBuf;
16use std::sync::Arc;
17
18use axum::extract::DefaultBodyLimit;
19use axum::{
20    Router, middleware,
21    routing::{get, post, put},
22};
23use tokio::sync::RwLock;
24
25use crate::config_runtime::ConfigApplyStatus;
26use roboticus_agent::policy::PolicyEngine;
27use roboticus_agent::subagents::SubagentRegistry;
28use roboticus_browser::Browser;
29use roboticus_channels::a2a::A2aProtocol;
30use roboticus_channels::router::ChannelRouter;
31use roboticus_channels::telegram::TelegramAdapter;
32use roboticus_channels::whatsapp::WhatsAppAdapter;
33use roboticus_core::RoboticusConfig;
34use roboticus_core::personality::{self, OsIdentity, OsVoice};
35use roboticus_db::Database;
36use roboticus_llm::LlmService;
37use roboticus_llm::OAuthManager;
38use roboticus_plugin_sdk::registry::PluginRegistry;
39use roboticus_wallet::WalletService;
40
41use roboticus_agent::approvals::ApprovalManager;
42use roboticus_agent::obsidian::ObsidianVault;
43use roboticus_agent::tools::ToolRegistry;
44use roboticus_channels::discord::DiscordAdapter;
45use roboticus_channels::email::EmailAdapter;
46use roboticus_channels::media::MediaService;
47use roboticus_channels::signal::SignalAdapter;
48use roboticus_channels::voice::VoicePipeline;
49
50use crate::ws::EventBus;
51
52// ── JSON error response type ─────────────────────────────────
53
54/// A JSON-formatted API error response. All error paths in the API return
55/// `{"error": "<message>"}` with the appropriate HTTP status code.
56#[derive(Debug)]
57pub(crate) struct JsonError(pub axum::http::StatusCode, pub String);
58
59impl axum::response::IntoResponse for JsonError {
60    fn into_response(self) -> axum::response::Response {
61        let body = serde_json::json!({ "error": self.1 });
62        (self.0, axum::Json(body)).into_response()
63    }
64}
65
66impl From<(axum::http::StatusCode, String)> for JsonError {
67    fn from((status, msg): (axum::http::StatusCode, String)) -> Self {
68        Self(status, msg)
69    }
70}
71
72/// Shorthand for a 400 Bad Request JSON error.
73pub(crate) fn bad_request(msg: impl std::fmt::Display) -> JsonError {
74    JsonError(axum::http::StatusCode::BAD_REQUEST, msg.to_string())
75}
76
77/// Shorthand for a 404 Not Found JSON error.
78pub(crate) fn not_found(msg: impl std::fmt::Display) -> JsonError {
79    JsonError(axum::http::StatusCode::NOT_FOUND, msg.to_string())
80}
81
82// ── Helpers (used by submodules) ──────────────────────────────
83
84/// Sanitizes error messages before returning to clients (strip paths, internal details, cap length).
85///
86/// LIMITATIONS: This is a best-effort filter that strips known wrapper
87/// prefixes and truncates. It does NOT guarantee that internal details
88/// (file paths, SQL fragments, stack traces) are fully redacted. If a new
89/// error source leaks sensitive info, add its prefix to the stripping list
90/// below or, better, ensure the call site maps the error before it reaches
91/// this function.
92pub(crate) fn sanitize_error_message(msg: &str) -> String {
93    let sanitized = msg.lines().next().unwrap_or(msg);
94
95    let sanitized = sanitized
96        .trim_start_matches("Database(\"")
97        .trim_end_matches("\")")
98        .trim_start_matches("Wallet(\"")
99        .trim_end_matches("\")");
100
101    // Strip content after common internal-detail prefixes that may leak
102    // implementation specifics (connection strings, file paths, etc.).
103    let sensitive_prefixes = [
104        "at /", // stack trace file paths
105        "called `Result::unwrap()` on an `Err` value:",
106        "SQLITE_",                // raw SQLite error codes
107        "Connection refused",     // infra details
108        "constraint failed",      // SQLite constraint errors (leaks table/column names)
109        "no such table",          // SQLite schema details
110        "no such column",         // SQLite schema details
111        "UNIQUE constraint",      // SQLite constraint (leaks table.column)
112        "FOREIGN KEY constraint", // SQLite constraint
113        "NOT NULL constraint",    // SQLite constraint
114    ];
115    let sanitized = {
116        let mut s = sanitized.to_string();
117        for prefix in &sensitive_prefixes {
118            if let Some(pos) = s.find(prefix) {
119                s.truncate(pos);
120                s.push_str("[details redacted]");
121                break;
122            }
123        }
124        s
125    };
126
127    if sanitized.len() > 200 {
128        let boundary = sanitized
129            .char_indices()
130            .map(|(i, _)| i)
131            .take_while(|&i| i <= 200)
132            .last()
133            .unwrap_or(0);
134        format!("{}...", &sanitized[..boundary])
135    } else {
136        sanitized
137    }
138}
139
140/// Logs the full error and returns a JSON 500 error for API responses.
141pub(crate) fn internal_err(e: &impl std::fmt::Display) -> JsonError {
142    tracing::error!(error = %e, "request failed");
143    JsonError(
144        axum::http::StatusCode::INTERNAL_SERVER_ERROR,
145        sanitize_error_message(&e.to_string()),
146    )
147}
148
149// ── Input validation helpers ──────────────────────────────────
150
151/// Maximum allowed length for short identifier fields (agent_id, name, etc.).
152const MAX_SHORT_FIELD: usize = 256;
153/// Maximum allowed length for long text fields (description, content, etc.).
154const MAX_LONG_FIELD: usize = 4096;
155
156/// Validate a user-supplied string field: reject empty/whitespace-only, null bytes, and enforce length.
157pub(crate) fn validate_field(
158    field_name: &str,
159    value: &str,
160    max_len: usize,
161) -> Result<(), JsonError> {
162    if value.trim().is_empty() {
163        return Err(bad_request(format!("{field_name} must not be empty")));
164    }
165    if value.contains('\0') {
166        return Err(bad_request(format!(
167            "{field_name} must not contain null bytes"
168        )));
169    }
170    if value.len() > max_len {
171        return Err(bad_request(format!(
172            "{field_name} exceeds max length ({max_len})"
173        )));
174    }
175    Ok(())
176}
177
178/// Validate a short identifier field (agent_id, name, session_id, etc.).
179pub(crate) fn validate_short(field_name: &str, value: &str) -> Result<(), JsonError> {
180    validate_field(field_name, value, MAX_SHORT_FIELD)
181}
182
183/// Validate a long text field (description, content, etc.).
184pub(crate) fn validate_long(field_name: &str, value: &str) -> Result<(), JsonError> {
185    validate_field(field_name, value, MAX_LONG_FIELD)
186}
187
188/// Strip HTML tags from a string to prevent injection in stored values.
189pub(crate) fn sanitize_html(input: &str) -> String {
190    input
191        .replace('&', "&amp;")
192        .replace('<', "&lt;")
193        .replace('>', "&gt;")
194        .replace('"', "&quot;")
195        .replace('\'', "&#x27;")
196}
197
198// ── Pagination helpers ──────────────────────────────────────────
199
200/// Default maximum items per page for list endpoints.
201const DEFAULT_PAGE_SIZE: i64 = 200;
202/// Absolute maximum items per page (prevents memory abuse via huge limits).
203const MAX_PAGE_SIZE: i64 = 500;
204
205/// Shared pagination query parameters for list endpoints.
206#[derive(Debug, serde::Deserialize)]
207pub(crate) struct PaginationQuery {
208    pub limit: Option<i64>,
209    pub offset: Option<i64>,
210}
211
212impl PaginationQuery {
213    /// Returns (limit, offset) clamped to safe ranges.
214    pub fn resolve(&self) -> (i64, i64) {
215        let limit = self
216            .limit
217            .unwrap_or(DEFAULT_PAGE_SIZE)
218            .clamp(1, MAX_PAGE_SIZE);
219        let offset = self.offset.unwrap_or(0).max(0);
220        (limit, offset)
221    }
222}
223
224// ── Shared state and types ────────────────────────────────────
225
226/// Holds the composed personality text plus metadata for status display.
227#[derive(Debug, Clone)]
228pub struct PersonalityState {
229    pub os_text: String,
230    pub firmware_text: String,
231    pub identity: OsIdentity,
232    pub voice: OsVoice,
233}
234
235impl PersonalityState {
236    pub fn from_workspace(workspace: &std::path::Path) -> Self {
237        let os = personality::load_os(workspace);
238        let fw = personality::load_firmware(workspace);
239        let operator = personality::load_operator(workspace);
240        let directives = personality::load_directives(workspace);
241
242        let os_text =
243            personality::compose_identity_text(os.as_ref(), operator.as_ref(), directives.as_ref());
244        let firmware_text = personality::compose_firmware_text(fw.as_ref());
245
246        let (identity, voice) = match os {
247            Some(os) => (os.identity, os.voice),
248            None => (
249                OsIdentity {
250                    name: String::new(),
251                    version: "1.0".into(),
252                    generated_by: "none".into(),
253                },
254                OsVoice::default(),
255            ),
256        };
257
258        Self {
259            os_text,
260            firmware_text,
261            identity,
262            voice,
263        }
264    }
265
266    pub fn empty() -> Self {
267        Self {
268            os_text: String::new(),
269            firmware_text: String::new(),
270            identity: OsIdentity {
271                name: String::new(),
272                version: "1.0".into(),
273                generated_by: "none".into(),
274            },
275            voice: OsVoice::default(),
276        }
277    }
278}
279
280/// Tracks a multi-turn personality interview for a single user.
281#[derive(Debug)]
282pub struct InterviewSession {
283    pub history: Vec<roboticus_llm::format::UnifiedMessage>,
284    pub awaiting_confirmation: bool,
285    pub pending_output: Option<roboticus_core::personality::InterviewOutput>,
286    pub created_at: std::time::Instant,
287}
288
289impl Default for InterviewSession {
290    fn default() -> Self {
291        Self::new()
292    }
293}
294
295impl InterviewSession {
296    pub fn new() -> Self {
297        Self {
298            history: vec![roboticus_llm::format::UnifiedMessage {
299                role: "system".into(),
300                content: roboticus_agent::interview::build_interview_prompt(),
301                parts: None,
302            }],
303            awaiting_confirmation: false,
304            pending_output: None,
305            created_at: std::time::Instant::now(),
306        }
307    }
308}
309
310#[derive(Clone)]
311pub struct AppState {
312    pub db: Database,
313    pub config: Arc<RwLock<RoboticusConfig>>,
314    pub llm: Arc<RwLock<LlmService>>,
315    pub wallet: Arc<WalletService>,
316    pub a2a: Arc<RwLock<A2aProtocol>>,
317    pub personality: Arc<RwLock<PersonalityState>>,
318    pub hmac_secret: Arc<Vec<u8>>,
319    pub interviews: Arc<RwLock<HashMap<String, InterviewSession>>>,
320    pub plugins: Arc<PluginRegistry>,
321    pub policy_engine: Arc<PolicyEngine>,
322    pub browser: Arc<Browser>,
323    pub registry: Arc<SubagentRegistry>,
324    pub event_bus: EventBus,
325    pub channel_router: Arc<ChannelRouter>,
326    pub telegram: Option<Arc<TelegramAdapter>>,
327    pub whatsapp: Option<Arc<WhatsAppAdapter>>,
328    pub retriever: Arc<roboticus_agent::retrieval::MemoryRetriever>,
329    pub ann_index: roboticus_db::ann::AnnIndex,
330    pub tools: Arc<ToolRegistry>,
331    pub approvals: Arc<ApprovalManager>,
332    pub discord: Option<Arc<DiscordAdapter>>,
333    pub signal: Option<Arc<SignalAdapter>>,
334    pub email: Option<Arc<EmailAdapter>>,
335    pub voice: Option<Arc<RwLock<VoicePipeline>>>,
336    pub media_service: Option<Arc<MediaService>>,
337    pub discovery: Arc<RwLock<roboticus_agent::discovery::DiscoveryRegistry>>,
338    pub devices: Arc<RwLock<roboticus_agent::device::DeviceManager>>,
339    pub mcp_clients: Arc<RwLock<roboticus_agent::mcp::McpClientManager>>,
340    pub mcp_server: Arc<RwLock<roboticus_agent::mcp::McpServerRegistry>>,
341    pub oauth: Arc<OAuthManager>,
342    pub keystore: Arc<roboticus_core::keystore::Keystore>,
343    pub obsidian: Option<Arc<RwLock<ObsidianVault>>>,
344    pub started_at: std::time::Instant,
345    pub config_path: Arc<PathBuf>,
346    pub config_apply_status: Arc<RwLock<ConfigApplyStatus>>,
347    pub pending_specialist_proposals: Arc<RwLock<HashMap<String, serde_json::Value>>>,
348    pub ws_tickets: crate::ws_ticket::TicketStore,
349    pub rate_limiter: crate::rate_limit::GlobalRateLimitLayer,
350}
351
352impl AppState {
353    pub async fn reload_personality(&self) {
354        let workspace = {
355            let config = self.config.read().await;
356            config.agent.workspace.clone()
357        };
358        let new_state = PersonalityState::from_workspace(&workspace);
359        tracing::info!(
360            personality = %new_state.identity.name,
361            generated_by = %new_state.identity.generated_by,
362            "Hot-reloaded personality from workspace"
363        );
364        *self.personality.write().await = new_state;
365    }
366}
367
368// ── JSON error normalization middleware ────────────────────────
369//
370// BUG-006/014/016/017: axum returns plain-text bodies for its built-in
371// rejections (JSON parse errors, wrong Content-Type, 405 Method Not
372// Allowed). This middleware intercepts any non-JSON error response and
373// wraps it in the standard `{"error":"..."}` format.
374
375async fn json_error_layer(
376    req: axum::extract::Request,
377    next: middleware::Next,
378) -> axum::response::Response {
379    let response = next.run(req).await;
380    let status = response.status();
381
382    if !(status.is_client_error() || status.is_server_error()) {
383        return response;
384    }
385
386    let is_json = response
387        .headers()
388        .get(axum::http::header::CONTENT_TYPE)
389        .and_then(|v| v.to_str().ok())
390        .is_some_and(|ct| ct.contains("application/json"));
391    if is_json {
392        return response;
393    }
394
395    let code = response.status();
396    let (_parts, body) = response.into_parts();
397    let bytes = match axum::body::to_bytes(body, 8192).await {
398        Ok(b) => b,
399        Err(e) => {
400            tracing::warn!(error = %e, "failed to read response body for JSON wrapping");
401            axum::body::Bytes::new()
402        }
403    };
404    let original_text = String::from_utf8_lossy(&bytes);
405
406    let error_msg = if original_text.trim().is_empty() {
407        match code {
408            axum::http::StatusCode::METHOD_NOT_ALLOWED => "method not allowed".to_string(),
409            axum::http::StatusCode::NOT_FOUND => "not found".to_string(),
410            axum::http::StatusCode::UNSUPPORTED_MEDIA_TYPE => {
411                "unsupported content type: expected application/json".to_string()
412            }
413            other => other.to_string(),
414        }
415    } else {
416        sanitize_error_message(original_text.trim())
417    };
418
419    let json_body = serde_json::json!({ "error": error_msg });
420    let body_bytes = serde_json::to_vec(&json_body)
421        .unwrap_or_else(|_| br#"{"error":"internal error"}"#.to_vec());
422    let mut resp = axum::response::Response::new(axum::body::Body::from(body_bytes));
423    *resp.status_mut() = code;
424    resp.headers_mut().insert(
425        axum::http::header::CONTENT_TYPE,
426        axum::http::HeaderValue::from_static("application/json"),
427    );
428    resp
429}
430
431// ── Security headers ─────────────────────────────────────────────
432// BUG-018: Content-Security-Policy
433// BUG-019: X-Frame-Options
434
435async fn security_headers_layer(
436    req: axum::extract::Request,
437    next: middleware::Next,
438) -> axum::response::Response {
439    let mut response = next.run(req).await;
440    let headers = response.headers_mut();
441    headers.insert(
442        axum::http::header::HeaderName::from_static("content-security-policy"),
443        axum::http::HeaderValue::from_static(
444            "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' ws: wss:; frame-ancestors 'none'",
445        ),
446    );
447    headers.insert(
448        axum::http::header::X_FRAME_OPTIONS,
449        axum::http::HeaderValue::from_static("DENY"),
450    );
451    headers.insert(
452        axum::http::header::X_CONTENT_TYPE_OPTIONS,
453        axum::http::HeaderValue::from_static("nosniff"),
454    );
455    response
456}
457
458async fn dashboard_redirect() -> axum::response::Redirect {
459    axum::response::Redirect::permanent("/")
460}
461
462// ── Router ──────────────────────────────────────────────────────
463
464pub fn build_router(state: AppState) -> Router {
465    use admin::{
466        a2a_hello, breaker_open, breaker_reset, breaker_status, browser_action, browser_start,
467        browser_status, browser_stop, change_agent_model, confirm_revenue_swap_task,
468        confirm_revenue_tax_task, create_service_quote, delete_provider_key, execute_plugin_tool,
469        fail_revenue_swap_task, fail_revenue_tax_task, fail_service_request,
470        fulfill_revenue_opportunity, fulfill_service_request, generate_deep_analysis, get_agents,
471        get_available_models, get_cache_stats, get_capacity_stats, get_config,
472        get_config_apply_status, get_config_capabilities, get_costs, get_efficiency,
473        get_mcp_runtime, get_overview_timeseries, get_plugins, get_recommendations,
474        get_revenue_opportunity, get_routing_dataset, get_routing_diagnostics,
475        get_runtime_surfaces, get_service_request, get_throttle_stats, get_transactions,
476        intake_micro_bounty_opportunity, intake_oracle_feed_opportunity,
477        intake_revenue_opportunity, list_discovered_agents, list_paired_devices,
478        list_revenue_opportunities, list_revenue_swap_tasks, list_revenue_tax_tasks,
479        list_services_catalog, mcp_client_disconnect, mcp_client_discover, pair_device,
480        plan_revenue_opportunity, qualify_revenue_opportunity, reconcile_revenue_swap_task,
481        reconcile_revenue_tax_task, record_revenue_opportunity_feedback, register_discovered_agent,
482        roster, run_routing_eval, score_revenue_opportunity, set_provider_key,
483        settle_revenue_opportunity, start_agent, start_revenue_swap_task, start_revenue_tax_task,
484        stop_agent, submit_revenue_swap_task, submit_revenue_tax_task, toggle_plugin,
485        unpair_device, update_config, verify_discovered_agent, verify_paired_device,
486        verify_service_payment, wallet_address, wallet_balance, workspace_state,
487    };
488    use agent::{agent_message, agent_message_stream, agent_status};
489    use channels::{get_channels_status, get_dead_letters, replay_dead_letter};
490    use cron::{
491        create_cron_job, delete_cron_job, get_cron_job, list_cron_jobs, list_cron_runs,
492        run_cron_job_now, update_cron_job,
493    };
494    use health::{get_logs, health};
495    use memory::{
496        get_episodic_memory, get_semantic_categories, get_semantic_memory, get_semantic_memory_all,
497        get_working_memory, get_working_memory_all, knowledge_ingest, memory_search,
498    };
499    use sessions::{
500        analyze_session, analyze_turn, backfill_nicknames, create_session, get_session,
501        get_session_feedback, get_session_insights, get_turn, get_turn_context, get_turn_feedback,
502        get_turn_model_selection, get_turn_tips, get_turn_tools, list_messages,
503        list_model_selection_events, list_session_turns, list_sessions, post_message,
504        post_turn_feedback, put_turn_feedback,
505    };
506    use skills::{
507        audit_skills, catalog_activate, catalog_install, catalog_list, delete_skill, get_skill,
508        list_skills, reload_skills, toggle_skill,
509    };
510    use subagents::{
511        create_sub_agent, delete_sub_agent, list_sub_agents, toggle_sub_agent, update_sub_agent,
512    };
513
514    Router::new()
515        .route("/", get(crate::dashboard::dashboard_handler))
516        .route("/dashboard", get(dashboard_redirect))
517        .route("/dashboard/", get(dashboard_redirect))
518        .route("/api/health", get(health))
519        .route("/health", get(health))
520        .route("/api/config", get(get_config).put(update_config))
521        .route("/api/config/capabilities", get(get_config_capabilities))
522        .route("/api/config/status", get(get_config_apply_status))
523        .route(
524            "/api/providers/{name}/key",
525            put(set_provider_key).delete(delete_provider_key),
526        )
527        .route("/api/logs", get(get_logs))
528        .route("/api/sessions", get(list_sessions).post(create_session))
529        .route("/api/sessions/backfill-nicknames", post(backfill_nicknames))
530        .route("/api/sessions/{id}", get(get_session))
531        .route(
532            "/api/sessions/{id}/messages",
533            get(list_messages).post(post_message),
534        )
535        .route("/api/sessions/{id}/turns", get(list_session_turns))
536        .route("/api/sessions/{id}/insights", get(get_session_insights))
537        .route("/api/sessions/{id}/feedback", get(get_session_feedback))
538        .route("/api/turns/{id}", get(get_turn))
539        .route("/api/turns/{id}/context", get(get_turn_context))
540        .route(
541            "/api/turns/{id}/model-selection",
542            get(get_turn_model_selection),
543        )
544        .route("/api/turns/{id}/tools", get(get_turn_tools))
545        .route("/api/turns/{id}/tips", get(get_turn_tips))
546        .route("/api/models/selections", get(list_model_selection_events))
547        .route(
548            "/api/turns/{id}/feedback",
549            get(get_turn_feedback)
550                .post(post_turn_feedback)
551                .put(put_turn_feedback),
552        )
553        .route("/api/memory/working", get(get_working_memory_all))
554        .route("/api/memory/working/{session_id}", get(get_working_memory))
555        .route("/api/memory/episodic", get(get_episodic_memory))
556        .route("/api/memory/semantic", get(get_semantic_memory_all))
557        .route(
558            "/api/memory/semantic/categories",
559            get(get_semantic_categories),
560        )
561        .route("/api/memory/semantic/{category}", get(get_semantic_memory))
562        .route("/api/memory/search", get(memory_search))
563        .route("/api/knowledge/ingest", post(knowledge_ingest))
564        .route("/api/cron/jobs", get(list_cron_jobs).post(create_cron_job))
565        .route("/api/cron/runs", get(list_cron_runs))
566        .route(
567            "/api/cron/jobs/{id}",
568            get(get_cron_job)
569                .put(update_cron_job)
570                .delete(delete_cron_job),
571        )
572        .route(
573            "/api/cron/jobs/{id}/run",
574            axum::routing::post(run_cron_job_now),
575        )
576        .route("/api/stats/costs", get(get_costs))
577        .route("/api/stats/timeseries", get(get_overview_timeseries))
578        .route("/api/stats/efficiency", get(get_efficiency))
579        .route("/api/recommendations", get(get_recommendations))
580        .route("/api/stats/transactions", get(get_transactions))
581        .route("/api/services/catalog", get(list_services_catalog))
582        .route("/api/services/quote", post(create_service_quote))
583        .route("/api/services/requests/{id}", get(get_service_request))
584        .route(
585            "/api/services/requests/{id}/payment/verify",
586            post(verify_service_payment),
587        )
588        .route(
589            "/api/services/requests/{id}/fulfill",
590            post(fulfill_service_request),
591        )
592        .route(
593            "/api/services/requests/{id}/fail",
594            post(fail_service_request),
595        )
596        .route(
597            "/api/services/opportunities/intake",
598            get(list_revenue_opportunities).post(intake_revenue_opportunity),
599        )
600        .route(
601            "/api/services/opportunities/adapters/micro-bounty/intake",
602            post(intake_micro_bounty_opportunity),
603        )
604        .route(
605            "/api/services/opportunities/adapters/oracle-feed/intake",
606            post(intake_oracle_feed_opportunity),
607        )
608        .route(
609            "/api/services/opportunities/{id}",
610            get(get_revenue_opportunity),
611        )
612        .route(
613            "/api/services/opportunities/{id}/score",
614            post(score_revenue_opportunity),
615        )
616        .route(
617            "/api/services/opportunities/{id}/qualify",
618            post(qualify_revenue_opportunity),
619        )
620        .route(
621            "/api/services/opportunities/{id}/feedback",
622            post(record_revenue_opportunity_feedback),
623        )
624        .route(
625            "/api/services/opportunities/{id}/plan",
626            post(plan_revenue_opportunity),
627        )
628        .route(
629            "/api/services/opportunities/{id}/fulfill",
630            post(fulfill_revenue_opportunity),
631        )
632        .route(
633            "/api/services/opportunities/{id}/settle",
634            post(settle_revenue_opportunity),
635        )
636        .route("/api/services/swaps", get(list_revenue_swap_tasks))
637        .route("/api/services/tax-payouts", get(list_revenue_tax_tasks))
638        .route(
639            "/api/services/swaps/{id}/start",
640            post(start_revenue_swap_task),
641        )
642        .route(
643            "/api/services/swaps/{id}/submit",
644            post(submit_revenue_swap_task),
645        )
646        .route(
647            "/api/services/swaps/{id}/reconcile",
648            post(reconcile_revenue_swap_task),
649        )
650        .route(
651            "/api/services/swaps/{id}/confirm",
652            post(confirm_revenue_swap_task),
653        )
654        .route(
655            "/api/services/swaps/{id}/fail",
656            post(fail_revenue_swap_task),
657        )
658        .route(
659            "/api/services/tax-payouts/{id}/start",
660            post(start_revenue_tax_task),
661        )
662        .route(
663            "/api/services/tax-payouts/{id}/submit",
664            post(submit_revenue_tax_task),
665        )
666        .route(
667            "/api/services/tax-payouts/{id}/reconcile",
668            post(reconcile_revenue_tax_task),
669        )
670        .route(
671            "/api/services/tax-payouts/{id}/confirm",
672            post(confirm_revenue_tax_task),
673        )
674        .route(
675            "/api/services/tax-payouts/{id}/fail",
676            post(fail_revenue_tax_task),
677        )
678        .route("/api/stats/cache", get(get_cache_stats))
679        .route("/api/stats/capacity", get(get_capacity_stats))
680        .route("/api/stats/throttle", get(get_throttle_stats))
681        .route("/api/models/available", get(get_available_models))
682        .route(
683            "/api/models/routing-diagnostics",
684            get(get_routing_diagnostics),
685        )
686        .route("/api/models/routing-dataset", get(get_routing_dataset))
687        .route("/api/models/routing-eval", post(run_routing_eval))
688        .route("/api/breaker/status", get(breaker_status))
689        .route("/api/breaker/open/{provider}", post(breaker_open))
690        .route("/api/breaker/reset/{provider}", post(breaker_reset))
691        .route("/api/agent/status", get(agent_status))
692        .route("/api/agent/message", post(agent_message))
693        .route("/api/agent/message/stream", post(agent_message_stream))
694        .route("/api/wallet/balance", get(wallet_balance))
695        .route("/api/wallet/address", get(wallet_address))
696        .route("/api/skills", get(list_skills))
697        .route("/api/skills/catalog", get(catalog_list))
698        .route("/api/skills/catalog/install", post(catalog_install))
699        .route("/api/skills/catalog/activate", post(catalog_activate))
700        .route("/api/skills/audit", get(audit_skills))
701        .route("/api/skills/{id}", get(get_skill).delete(delete_skill))
702        .route("/api/skills/reload", post(reload_skills))
703        .route("/api/skills/{id}/toggle", put(toggle_skill))
704        .route("/api/plugins", get(get_plugins))
705        .route("/api/plugins/{name}/toggle", put(toggle_plugin))
706        .route(
707            "/api/plugins/{name}/execute/{tool}",
708            post(execute_plugin_tool),
709        )
710        .route("/api/browser/status", get(browser_status))
711        .route("/api/browser/start", post(browser_start))
712        .route("/api/browser/stop", post(browser_stop))
713        .route("/api/browser/action", post(browser_action))
714        .route("/api/agents", get(get_agents))
715        .route("/api/agents/{id}/start", post(start_agent))
716        .route("/api/agents/{id}/stop", post(stop_agent))
717        .route(
718            "/api/subagents",
719            get(list_sub_agents).post(create_sub_agent),
720        )
721        .route(
722            "/api/subagents/{name}",
723            put(update_sub_agent).delete(delete_sub_agent),
724        )
725        .route("/api/subagents/{name}/toggle", put(toggle_sub_agent))
726        .route("/api/workspace/state", get(workspace_state))
727        .route("/api/roster", get(roster))
728        .route("/api/roster/{name}/model", put(change_agent_model))
729        .route("/api/a2a/hello", post(a2a_hello))
730        .route("/api/channels/status", get(get_channels_status))
731        .route("/api/channels/dead-letter", get(get_dead_letters))
732        .route(
733            "/api/channels/dead-letter/{id}/replay",
734            post(replay_dead_letter),
735        )
736        .route("/api/runtime/surfaces", get(get_runtime_surfaces))
737        .route(
738            "/api/runtime/discovery",
739            get(list_discovered_agents).post(register_discovered_agent),
740        )
741        .route(
742            "/api/runtime/discovery/{id}/verify",
743            post(verify_discovered_agent),
744        )
745        .route("/api/runtime/devices", get(list_paired_devices))
746        .route("/api/runtime/devices/pair", post(pair_device))
747        .route(
748            "/api/runtime/devices/{id}/verify",
749            post(verify_paired_device),
750        )
751        .route(
752            "/api/runtime/devices/{id}",
753            axum::routing::delete(unpair_device),
754        )
755        .route("/api/runtime/mcp", get(get_mcp_runtime))
756        .route(
757            "/api/runtime/mcp/clients/{name}/discover",
758            post(mcp_client_discover),
759        )
760        .route(
761            "/api/runtime/mcp/clients/{name}/disconnect",
762            post(mcp_client_disconnect),
763        )
764        .route("/api/approvals", get(admin::list_approvals))
765        .route("/api/approvals/{id}/approve", post(admin::approve_request))
766        .route("/api/approvals/{id}/deny", post(admin::deny_request))
767        .route("/api/ws-ticket", post(admin::issue_ws_ticket))
768        .route("/api/interview/start", post(interview::start_interview))
769        .route("/api/interview/turn", post(interview::interview_turn))
770        .route("/api/interview/finish", post(interview::finish_interview))
771        .route("/api/audit/policy/{turn_id}", get(admin::get_policy_audit))
772        .route("/api/audit/tools/{turn_id}", get(admin::get_tool_audit))
773        .route(
774            "/favicon.ico",
775            get(|| async { axum::http::StatusCode::NO_CONTENT }),
776        )
777        // LLM analysis routes have their own concurrency limit to prevent
778        // expensive analysis requests from starving lightweight API calls.
779        .merge(
780            Router::new()
781                .route("/api/sessions/{id}/analyze", post(analyze_session))
782                .route("/api/turns/{id}/analyze", post(analyze_turn))
783                .route(
784                    "/api/recommendations/generate",
785                    post(generate_deep_analysis),
786                )
787                .layer(tower::limit::ConcurrencyLimitLayer::new(3))
788                .with_state(state.clone()),
789        )
790        .fallback(|| async { JsonError(axum::http::StatusCode::NOT_FOUND, "not found".into()) })
791        .layer(DefaultBodyLimit::max(1024 * 1024)) // 1MB
792        .layer(middleware::from_fn(json_error_layer))
793        .layer(middleware::from_fn(security_headers_layer))
794        .with_state(state)
795}
796
797/// Routes that must be accessible without API key authentication
798/// (webhooks from external services, discovery endpoints).
799pub fn build_public_router(state: AppState) -> Router {
800    use admin::agent_card;
801    use channels::{webhook_telegram, webhook_whatsapp, webhook_whatsapp_verify};
802
803    Router::new()
804        .route("/.well-known/agent.json", get(agent_card))
805        .route("/api/webhooks/telegram", post(webhook_telegram))
806        .route(
807            "/api/webhooks/whatsapp",
808            get(webhook_whatsapp_verify).post(webhook_whatsapp),
809        )
810        .layer(DefaultBodyLimit::max(1024 * 1024)) // 1MB — match auth router
811        .with_state(state)
812}
813
814// ── MCP Gateway (P.1) ─────────────────────────────────────────
815
816/// Builds an axum `Router` that serves the MCP protocol endpoint.
817///
818/// The returned router should be merged at the top level — it handles
819/// its own transport (POST for JSON-RPC, GET for SSE, DELETE for sessions)
820/// under the `/mcp` prefix via rmcp's `StreamableHttpService`.
821///
822/// Auth: MCP clients authenticate via `Authorization: Bearer <api_key>`.
823/// The same API key used for the REST API is accepted here.
824pub fn build_mcp_router(state: &AppState, api_key: Option<String>) -> Router {
825    use crate::auth::ApiKeyLayer;
826    use roboticus_agent::mcp_handler::{RoboticusMcpHandler, McpToolContext};
827    use rmcp::transport::streamable_http_server::{
828        StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager,
829    };
830    use std::time::Duration;
831
832    let mcp_ctx = McpToolContext {
833        agent_id: "roboticus-mcp-gateway".to_string(),
834        agent_name: state
835            .config
836            .try_read()
837            .map(|c| c.agent.name.clone())
838            .unwrap_or_else(|_| "roboticus".to_string()),
839        workspace_root: state
840            .config
841            .try_read()
842            .map(|c| c.agent.workspace.clone())
843            .unwrap_or_else(|_| std::path::PathBuf::from(".")),
844        db: Some(state.db.clone()),
845    };
846
847    let handler = RoboticusMcpHandler::new(state.tools.clone(), mcp_ctx);
848
849    let config = StreamableHttpServerConfig {
850        sse_keep_alive: Some(Duration::from_secs(15)),
851        stateful_mode: true,
852        ..Default::default()
853    };
854
855    let service = StreamableHttpService::new(
856        move || Ok(handler.clone()),
857        Arc::new(LocalSessionManager::default()),
858        config,
859    );
860
861    Router::new()
862        .nest_service("/mcp", service)
863        .layer(ApiKeyLayer::new(api_key))
864}
865
866// ── Re-exports for api.rs and lib.rs ────────────────────────────
867
868pub use agent::{discord_poll_loop, email_poll_loop, signal_poll_loop, telegram_poll_loop};
869pub use health::LogEntry;
870
871// ── Tests ─────────────────────────────────────────────────────
872
873#[cfg(test)]
874#[path = "tests.rs"]
875mod tests;