From 7fbedc8be47c14a335b038265fb393afaf024e9e Mon Sep 17 00:00:00 2001 From: Illia Polosukhin Date: Sat, 18 Apr 2026 09:56:30 +0000 Subject: [PATCH 1/2] =?UTF-8?q?refactor(gateway):=20extract=20start=5Fserv?= =?UTF-8?q?er=20and=20route=20composition=20into=20platform/router.rs=20?= =?UTF-8?q?=E2=80=94=20ironclaw#2599=20stage=202?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second increment of the ironclaw#2599 platform/feature split. Moves `start_server()` and the Axum route composition out of `server.rs` into a dedicated `platform/router.rs`, so the platform-vs-features dependency direction is visible: the router depends on handler modules (both `handlers/*` and the still-inline handlers in `server.rs`), never the reverse. Changes: - New `src/channels/web/platform/router.rs` owns `start_server()`, the four routers (`public`, `protected`, `statics`, `projects`), and the cross-cutting layer stack (CORS, 10 MB body limit, panic catch, `X-Content-Type-Options`, `X-Frame-Options`, CSP). - Feature handlers still inline in `server.rs` are now `pub(crate)` so the router can register them without leaking them outside the crate. Two private structs that are directly referenced by pub(crate) handlers (`HistoryQuery`, `GatewayStatusResponse`) were also raised to `pub(crate)` so the handler signatures type-check from the router module. - `server.rs` keeps the feature handlers that haven't migrated yet (OAuth callbacks, chat, extensions, pairing, logs, gateway status) and adds `pub use platform::router::start_server` so external call sites — `src/channels/web/mod.rs`, `tests/multi_tenant_integration.rs` — keep working. The trimmed imports drop `Router`, `DefaultBodyLimit`, `CorsLayer`, `tokio::sync::mpsc`, etc., since they're no longer used in the remaining body. - `mod.rs` now calls `platform::router::start_server` directly; the `server::start_server` shim exists only for external code paths that still reach for it. - `CLAUDE.md` file map now lists `platform/router.rs` and clarifies that `server.rs` is feature-handler-only pending migration. No behavior change. Route table, middleware stack, CORS policy, body limits, panic handling, security headers, and CSP are byte-identical to origin/staging. Stats: server.rs 7463 → 6973 lines (−490); new `platform/router.rs` is 537 lines. `cargo clippy --all --benches --tests --examples --all-features` is clean. Unit test run: 5068 passed; the same 2 pre-existing failures carried over from stage 1 (`pairing::approval::tests::propagate_approval_restores_runtime_state_when_on_start_fails` needs a telegram WASM fixture; `extensions::manager::tests::test_telegram_token_colon_preserved_in_validation_url` has a test-infra URL override — neither references any symbol this PR touches). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/channels/web/CLAUDE.md | 3 +- src/channels/web/platform/mod.rs | 1 + src/channels/web/platform/router.rs | 537 +++++++++++++++++++++++++ src/channels/web/server.rs | 600 +++------------------------- 4 files changed, 595 insertions(+), 546 deletions(-) create mode 100644 src/channels/web/platform/router.rs diff --git a/src/channels/web/CLAUDE.md b/src/channels/web/CLAUDE.md index b8a22225b09..31d40fe6dd3 100644 --- a/src/channels/web/CLAUDE.md +++ b/src/channels/web/CLAUDE.md @@ -7,7 +7,8 @@ Browser-facing HTTP API and SSE/WebSocket real-time streaming. Axum-based, singl | File | Role | |------|------| | `mod.rs` | Gateway builder, startup, `WebChannel` implementation, `with_*` builder methods | -| `server.rs` | `start_server()`, Axum route registrations, and feature handlers that have not yet moved (OAuth callbacks, chat, extensions, pairing, logs, gateway status). Re-exports `GatewayState` and friends from `platform::state` for backward compatibility during the ironclaw#2599 migration. | +| `server.rs` | Feature handlers that have not yet moved (OAuth callbacks, chat, extensions, pairing, logs, gateway status). Re-exports `GatewayState` / `start_server` / related types from `platform::*` for backward compatibility during the ironclaw#2599 migration. | +| `platform/router.rs` | `start_server()` + Axum route composition (public / protected / statics / projects) and the cross-cutting layer stack (CORS, body limit, panic catch, static security headers, CSP). Single coupling point between platform and features. | | `platform/state.rs` | `GatewayState`, `RateLimiter`, `PerUserRateLimiter`, `WorkspacePool`, `FrontendHtmlCache`, `FrontendCacheKey`, `ActiveConfigSnapshot`, `PromptQueue`, `RoutineEngineSlot`. Canonical home for shared gateway state. | | `platform/static_files.rs` | CSP directive set + `BASE_CSP_HEADER` (single source of truth), frontend HTML bundle assembly (`build_frontend_html`), and the unauthenticated static handlers: `/`, `/style.css`, `/app.js`, `/theme.css`, `/favicon.ico`, `/i18n/*`, `/admin*`, `/api/health`, plus the authenticated `/projects/{id}/...` file-serving routes. | | `types.rs` | Request/response DTOs and `SseEvent` enum (source of truth for SSE contract) | diff --git a/src/channels/web/platform/mod.rs b/src/channels/web/platform/mod.rs index f381ba5269c..f68ee9a3954 100644 --- a/src/channels/web/platform/mod.rs +++ b/src/channels/web/platform/mod.rs @@ -8,5 +8,6 @@ //! //! See `src/channels/web/CLAUDE.md` for the staged migration plan. +pub mod router; pub mod state; pub mod static_files; diff --git a/src/channels/web/platform/router.rs b/src/channels/web/platform/router.rs new file mode 100644 index 00000000000..67f4de11f92 --- /dev/null +++ b/src/channels/web/platform/router.rs @@ -0,0 +1,537 @@ +//! Axum route composition and server bootstrap. +//! +//! This module owns the wiring between the platform layer and the feature +//! handlers: `start_server` binds the TCP listener, assembles the four +//! routers (`public`, `protected`, `statics`, `projects`), and applies the +//! cross-cutting layers (CORS, body-size limit, panic catch, static +//! security headers, CSP). +//! +//! Per ironclaw#2599: route composition is the single coupling point +//! where platform meets features. Handlers themselves live in either +//! `handlers/.rs` (transitional) or `crate::channels::web::server` +//! (inline, pending migration into `features//`). The router +//! module depends on both; handlers must not depend on the router. + +use std::net::SocketAddr; +use std::sync::Arc; + +use axum::{ + Router, + extract::DefaultBodyLimit, + http::header, + middleware, + routing::{get, post, put}, +}; +use tokio::sync::oneshot; +use tower_http::cors::{AllowHeaders, CorsLayer}; +use tower_http::set_header::SetResponseHeaderLayer; + +use crate::channels::web::auth::{CombinedAuthState, auth_middleware}; +use crate::channels::web::handlers::chat::chat_events_handler; +use crate::channels::web::handlers::engine::{ + engine_mission_detail_handler, engine_mission_fire_handler, engine_mission_pause_handler, + engine_mission_resume_handler, engine_missions_handler, engine_missions_summary_handler, + engine_project_detail_handler, engine_projects_handler, engine_projects_overview_handler, + engine_thread_detail_handler, engine_thread_events_handler, engine_thread_steps_handler, + engine_threads_handler, +}; +use crate::channels::web::handlers::frontend::{ + frontend_layout_handler, frontend_layout_update_handler, frontend_widget_file_handler, + frontend_widgets_handler, +}; +use crate::channels::web::handlers::jobs::{ + job_files_list_handler, job_files_read_handler, jobs_cancel_handler, jobs_detail_handler, + jobs_events_handler, jobs_list_handler, jobs_prompt_handler, jobs_restart_handler, + jobs_summary_handler, +}; +use crate::channels::web::handlers::llm::{ + llm_list_models_handler, llm_providers_handler, llm_test_connection_handler, +}; +use crate::channels::web::handlers::memory::{ + memory_list_handler, memory_read_handler, memory_search_handler, memory_tree_handler, + memory_write_handler, +}; +use crate::channels::web::handlers::routines::{ + routines_delete_handler, routines_detail_handler, routines_list_handler, + routines_summary_handler, routines_toggle_handler, routines_trigger_handler, +}; +use crate::channels::web::handlers::settings::{ + settings_delete_handler, settings_export_handler, settings_get_handler, + settings_import_handler, settings_list_handler, settings_set_handler, + settings_tools_list_handler, settings_tools_set_handler, +}; +use crate::channels::web::handlers::skills::{ + skills_install_handler, skills_list_handler, skills_remove_handler, skills_search_handler, +}; +use crate::channels::web::platform::state::GatewayState; +use crate::channels::web::platform::static_files::{ + BASE_CSP_HEADER, admin_css_handler, admin_html_handler, admin_js_handler, css_handler, + favicon_handler, health_handler, i18n_app_handler, i18n_en_handler, i18n_index_handler, + i18n_ko_handler, i18n_zh_handler, index_handler, js_handler, project_file_handler, + project_index_handler, project_redirect_handler, theme_css_handler, theme_init_handler, +}; + +// Feature handlers still inline in `server.rs` pending migration into +// `features//`. Kept `pub(crate)` so the router can reference them +// without exposing them outside the crate. +use crate::channels::web::server::{ + chat_approval_handler, chat_auth_cancel_handler, chat_auth_token_handler, + chat_gate_resolve_handler, chat_history_handler, chat_new_thread_handler, chat_send_handler, + chat_threads_handler, chat_ws_handler, extensions_activate_handler, extensions_install_handler, + extensions_list_handler, extensions_readiness_handler, extensions_registry_handler, + extensions_remove_handler, extensions_setup_handler, extensions_setup_submit_handler, + extensions_tools_handler, gateway_status_handler, logs_events_handler, logs_level_get_handler, + logs_level_set_handler, oauth_callback_handler, pairing_approve_handler, pairing_list_handler, + relay_events_handler, routines_runs_handler, slack_relay_oauth_callback_handler, +}; + +/// Start the gateway HTTP server. +/// +/// Returns the actual bound `SocketAddr` (useful when binding to port 0). +pub async fn start_server( + addr: SocketAddr, + state: Arc, + auth: CombinedAuthState, +) -> Result { + let listener = tokio::net::TcpListener::bind(addr).await.map_err(|e| { + crate::error::ChannelError::StartupFailed { + name: "gateway".to_string(), + reason: format!("Failed to bind to {}: {}", addr, e), + } + })?; + let bound_addr = + listener + .local_addr() + .map_err(|e| crate::error::ChannelError::StartupFailed { + name: "gateway".to_string(), + reason: format!("Failed to get local addr: {}", e), + })?; + + // Public routes (no auth) + let public = Router::new() + .route("/api/health", get(health_handler)) + .route("/oauth/callback", get(oauth_callback_handler)) + .route( + "/oauth/slack/callback", + get(slack_relay_oauth_callback_handler), + ) + .route("/relay/events", post(relay_events_handler)) + .route( + "/api/webhooks/{path}", + post(crate::channels::web::handlers::webhooks::webhook_trigger_handler), + ) + // User-scoped webhook endpoint for multi-tenant isolation + .route( + "/api/webhooks/u/{user_id}/{path}", + post(crate::channels::web::handlers::webhooks::webhook_trigger_user_scoped_handler), + ) + // OAuth social login routes (public, no auth required) + .route( + "/auth/providers", + get(crate::channels::web::handlers::auth::providers_handler), + ) + .route( + "/auth/login/{provider}", + get(crate::channels::web::handlers::auth::login_handler), + ) + .route( + "/auth/callback/{provider}", + get(crate::channels::web::handlers::auth::callback_handler) + .post(crate::channels::web::handlers::auth::callback_post_handler), + ) + .route( + "/auth/logout", + post(crate::channels::web::handlers::auth::logout_handler), + ) + // NEAR wallet auth (challenge-response, not OAuth redirect) + .route( + "/auth/near/challenge", + get(crate::channels::web::handlers::auth::near_challenge_handler), + ) + .route( + "/auth/near/verify", + post(crate::channels::web::handlers::auth::near_verify_handler), + ); + + // Protected routes (require auth) + let auth_state = auth; + let protected = Router::new() + // Chat + .route("/api/chat/send", post(chat_send_handler)) + .route("/api/chat/gate/resolve", post(chat_gate_resolve_handler)) + .route("/api/chat/auth-token", post(chat_auth_token_handler)) + .route("/api/chat/auth-cancel", post(chat_auth_cancel_handler)) + .route("/api/chat/approval", post(chat_approval_handler)) + .route("/api/chat/events", get(chat_events_handler)) + .route("/api/chat/ws", get(chat_ws_handler)) + .route("/api/chat/history", get(chat_history_handler)) + .route("/api/chat/threads", get(chat_threads_handler)) + .route("/api/chat/thread/new", post(chat_new_thread_handler)) + // Memory + .route("/api/memory/tree", get(memory_tree_handler)) + .route("/api/memory/list", get(memory_list_handler)) + .route("/api/memory/read", get(memory_read_handler)) + .route("/api/memory/write", post(memory_write_handler)) + .route("/api/memory/search", post(memory_search_handler)) + // Jobs + .route("/api/jobs", get(jobs_list_handler)) + .route("/api/jobs/summary", get(jobs_summary_handler)) + .route("/api/jobs/{id}", get(jobs_detail_handler)) + .route("/api/jobs/{id}/cancel", post(jobs_cancel_handler)) + .route("/api/jobs/{id}/restart", post(jobs_restart_handler)) + .route("/api/jobs/{id}/prompt", post(jobs_prompt_handler)) + .route("/api/jobs/{id}/events", get(jobs_events_handler)) + .route("/api/jobs/{id}/files/list", get(job_files_list_handler)) + .route("/api/jobs/{id}/files/read", get(job_files_read_handler)) + // Logs + .route("/api/logs/events", get(logs_events_handler)) + .route("/api/logs/level", get(logs_level_get_handler)) + .route( + "/api/logs/level", + axum::routing::put(logs_level_set_handler), + ) + // Extensions + .route("/api/extensions", get(extensions_list_handler)) + .route( + "/api/extensions/readiness", + get(extensions_readiness_handler), + ) + .route("/api/extensions/tools", get(extensions_tools_handler)) + .route("/api/extensions/registry", get(extensions_registry_handler)) + .route("/api/extensions/install", post(extensions_install_handler)) + .route( + "/api/extensions/{name}/activate", + post(extensions_activate_handler), + ) + .route( + "/api/extensions/{name}/remove", + post(extensions_remove_handler), + ) + .route( + "/api/extensions/{name}/setup", + get(extensions_setup_handler).post(extensions_setup_submit_handler), + ) + // Pairing + .route("/api/pairing/{channel}", get(pairing_list_handler)) + .route( + "/api/pairing/{channel}/approve", + post(pairing_approve_handler), + ) + // Routines + .route("/api/routines", get(routines_list_handler)) + .route("/api/routines/summary", get(routines_summary_handler)) + .route("/api/routines/{id}", get(routines_detail_handler)) + .route("/api/routines/{id}/trigger", post(routines_trigger_handler)) + .route("/api/routines/{id}/toggle", post(routines_toggle_handler)) + .route( + "/api/routines/{id}", + axum::routing::delete(routines_delete_handler), + ) + .route("/api/routines/{id}/runs", get(routines_runs_handler)) + // Engine v2 + .route("/api/engine/threads", get(engine_threads_handler)) + .route( + "/api/engine/threads/{id}", + get(engine_thread_detail_handler), + ) + .route( + "/api/engine/threads/{id}/steps", + get(engine_thread_steps_handler), + ) + .route( + "/api/engine/threads/{id}/events", + get(engine_thread_events_handler), + ) + .route("/api/engine/projects", get(engine_projects_handler)) + .route( + "/api/engine/projects/overview", + get(engine_projects_overview_handler), + ) + .route( + "/api/engine/projects/{id}", + get(engine_project_detail_handler), + ) + .route( + "/api/engine/projects/{id}/widgets", + get(crate::channels::web::handlers::frontend::project_widgets_handler), + ) + .route("/api/engine/missions", get(engine_missions_handler)) + .route( + "/api/engine/missions/summary", + get(engine_missions_summary_handler), + ) + .route( + "/api/engine/missions/{id}", + get(engine_mission_detail_handler), + ) + .route( + "/api/engine/missions/{id}/fire", + post(engine_mission_fire_handler), + ) + .route( + "/api/engine/missions/{id}/pause", + post(engine_mission_pause_handler), + ) + .route( + "/api/engine/missions/{id}/resume", + post(engine_mission_resume_handler), + ) + // Skills + .route("/api/skills", get(skills_list_handler)) + .route("/api/skills/search", post(skills_search_handler)) + .route("/api/skills/install", post(skills_install_handler)) + .route( + "/api/skills/{name}", + axum::routing::delete(skills_remove_handler), + ) + // Settings + .route("/api/settings", get(settings_list_handler)) + .route("/api/settings/export", get(settings_export_handler)) + .route("/api/settings/import", post(settings_import_handler)) + // NOTE: These static routes intentionally shadow `/api/settings/{key}` when + // key="tools". Axum resolves static routes before parameterized ones, so this + // works correctly. Avoid adding a setting named literally "tools". + .route("/api/settings/tools", get(settings_tools_list_handler)) + .route( + "/api/settings/tools/{name}", + axum::routing::put(settings_tools_set_handler), + ) + .route("/api/settings/{key}", get(settings_get_handler)) + .route( + "/api/settings/{key}", + axum::routing::put(settings_set_handler), + ) + .route( + "/api/settings/{key}", + axum::routing::delete(settings_delete_handler), + ) + // LLM utilities + .route( + "/api/llm/test_connection", + post(llm_test_connection_handler), + ) + .route("/api/llm/list_models", post(llm_list_models_handler)) + .route("/api/llm/providers", get(llm_providers_handler)) + // User management (admin) + .route( + "/api/admin/users", + get(crate::channels::web::handlers::users::users_list_handler) + .post(crate::channels::web::handlers::users::users_create_handler), + ) + .route( + "/api/admin/users/{id}", + get(crate::channels::web::handlers::users::users_detail_handler) + .patch(crate::channels::web::handlers::users::users_update_handler) + .delete(crate::channels::web::handlers::users::users_delete_handler), + ) + .route( + "/api/admin/users/{id}/suspend", + post(crate::channels::web::handlers::users::users_suspend_handler), + ) + .route( + "/api/admin/users/{id}/activate", + post(crate::channels::web::handlers::users::users_activate_handler), + ) + // Admin secrets provisioning (per-user) + .route( + "/api/admin/users/{user_id}/secrets", + get(crate::channels::web::handlers::secrets::secrets_list_handler), + ) + .route( + "/api/admin/users/{user_id}/secrets/{name}", + put(crate::channels::web::handlers::secrets::secrets_put_handler) + .delete(crate::channels::web::handlers::secrets::secrets_delete_handler), + ) + // Admin tool policy + .route( + "/api/admin/tool-policy", + get(crate::channels::web::handlers::tool_policy::tool_policy_get_handler) + .put(crate::channels::web::handlers::tool_policy::tool_policy_put_handler), + ) + // Admin system prompt — tighter body cap than the global 10 MB so an + // oversized payload is rejected before being parsed into memory. + .route( + "/api/admin/system-prompt", + get(crate::channels::web::handlers::system_prompt::get_handler) + .put(crate::channels::web::handlers::system_prompt::put_handler) + .layer(DefaultBodyLimit::max(128 * 1024)), + ) + // Usage reporting (admin) + .route( + "/api/admin/usage", + get(crate::channels::web::handlers::users::usage_stats_handler), + ) + .route( + "/api/admin/usage/summary", + get(crate::channels::web::handlers::users::usage_summary_handler), + ) + // User self-service profile + .route( + "/api/profile", + get(crate::channels::web::handlers::users::profile_get_handler) + .patch(crate::channels::web::handlers::users::profile_update_handler), + ) + // Token management + .route( + "/api/tokens", + get(crate::channels::web::handlers::tokens::tokens_list_handler) + .post(crate::channels::web::handlers::tokens::tokens_create_handler), + ) + .route( + "/api/tokens/{id}", + axum::routing::delete(crate::channels::web::handlers::tokens::tokens_revoke_handler), + ) + // Frontend extension API + .route( + "/api/frontend/layout", + get(frontend_layout_handler).put(frontend_layout_update_handler), + ) + .route("/api/frontend/widgets", get(frontend_widgets_handler)) + .route( + "/api/frontend/widget/{id}/{*file}", + get(frontend_widget_file_handler), + ) + // Gateway control plane + .route("/api/gateway/status", get(gateway_status_handler)) + // OpenAI-compatible API + .route( + "/v1/chat/completions", + post(crate::channels::web::openai_compat::chat_completions_handler), + ) + .route( + "/v1/models", + get(crate::channels::web::openai_compat::models_handler), + ) + // OpenAI Responses API (routes through the full agent loop) + .route( + "/v1/responses", + post(crate::channels::web::responses_api::create_response_handler), + ) + .route( + "/v1/responses/{id}", + get(crate::channels::web::responses_api::get_response_handler), + ) + .route_layer(middleware::from_fn_with_state( + auth_state.clone(), + auth_middleware, + )); + + // Static file routes (no auth, served from embedded strings) + let statics = Router::new() + .route("/", get(index_handler)) + .route("/theme.css", get(theme_css_handler)) + .route("/style.css", get(css_handler)) + .route("/app.js", get(js_handler)) + .route("/theme-init.js", get(theme_init_handler)) + .route("/favicon.ico", get(favicon_handler)) + .route("/i18n/index.js", get(i18n_index_handler)) + .route("/i18n/en.js", get(i18n_en_handler)) + .route("/i18n/zh-CN.js", get(i18n_zh_handler)) + .route("/i18n/ko.js", get(i18n_ko_handler)) + .route("/i18n-app.js", get(i18n_app_handler)) + // Admin panel SPA (auth handled client-side + API layer) + .route("/admin", get(admin_html_handler)) + .route("/admin/", get(admin_html_handler)) + .route("/admin/{*path}", get(admin_html_handler)) + .route("/admin.css", get(admin_css_handler)) + .route("/admin.js", get(admin_js_handler)); + + // Project file serving (behind auth to prevent unauthorized file access). + let projects = Router::new() + .route("/projects/{project_id}", get(project_redirect_handler)) + .route("/projects/{project_id}/", get(project_index_handler)) + .route("/projects/{project_id}/{*path}", get(project_file_handler)) + .route_layer(middleware::from_fn_with_state( + auth_state.clone(), + auth_middleware, + )); + + // CORS: restrict to same-origin by default. Only localhost/127.0.0.1 + // origins are allowed, since the gateway is a local-first service. + let cors = CorsLayer::new() + .allow_origin([ + format!("http://{}:{}", addr.ip(), addr.port()) + .parse() + .expect("valid origin"), + format!("http://localhost:{}", addr.port()) + .parse() + .expect("valid origin"), + ]) + .allow_methods([ + axum::http::Method::GET, + axum::http::Method::POST, + axum::http::Method::PUT, + axum::http::Method::PATCH, + axum::http::Method::DELETE, + ]) + .allow_headers(AllowHeaders::list([ + header::CONTENT_TYPE, + header::AUTHORIZATION, + ])) + .allow_credentials(true); + + let app = Router::new() + .merge(public) + .merge(statics) + .merge(projects) + .merge(protected) + .layer(DefaultBodyLimit::max(10 * 1024 * 1024)) // 10 MB max request body (image uploads) + .layer(tower_http::catch_panic::CatchPanicLayer::custom( + |panic_info: Box| { + let detail = if let Some(s) = panic_info.downcast_ref::() { + s.clone() + } else if let Some(s) = panic_info.downcast_ref::<&str>() { + (*s).to_string() + } else { + "unknown panic".to_string() + }; + // Truncate panic payload to avoid leaking sensitive data into logs. + // Use floor_char_boundary to avoid panicking on multi-byte UTF-8. + let safe_detail = if detail.len() > 200 { + let end = detail.floor_char_boundary(200); + format!("{}…", &detail[..end]) + } else { + detail + }; + tracing::error!("Handler panicked: {}", safe_detail); + axum::http::Response::builder() + .status(axum::http::StatusCode::INTERNAL_SERVER_ERROR) + .header("content-type", "text/plain") + .body(axum::body::Body::from("Internal Server Error")) + .unwrap_or_else(|_| { + axum::http::Response::new(axum::body::Body::from("Internal Server Error")) + }) + }, + )) + .layer(cors) + .layer(SetResponseHeaderLayer::if_not_present( + header::X_CONTENT_TYPE_OPTIONS, + header::HeaderValue::from_static("nosniff"), + )) + .layer(SetResponseHeaderLayer::if_not_present( + header::X_FRAME_OPTIONS, + header::HeaderValue::from_static("DENY"), + )) + .layer(SetResponseHeaderLayer::if_not_present( + header::HeaderName::from_static("content-security-policy"), + BASE_CSP_HEADER.clone(), + )) + .with_state(state.clone()); + + let (shutdown_tx, shutdown_rx) = oneshot::channel(); + *state.shutdown_tx.write().await = Some(shutdown_tx); + + tokio::spawn(async move { + if let Err(e) = axum::serve(listener, app) + .with_graceful_shutdown(async { + let _ = shutdown_rx.await; + tracing::debug!("Web gateway shutting down"); + }) + .await + { + tracing::error!("Web gateway server error: {}", e); + } + }); + + Ok(bound_addr) +} diff --git a/src/channels/web/server.rs b/src/channels/web/server.rs index da8b7308520..25fc1f1288d 100644 --- a/src/channels/web/server.rs +++ b/src/channels/web/server.rs @@ -1,83 +1,33 @@ -//! Axum HTTP server for the web gateway. +//! Feature handlers for the web gateway that have not yet migrated to +//! domain modules under `handlers/` or `features//`. //! -//! Owns `start_server()` and the feature handlers that have not yet moved -//! to domain modules. The platform-level pieces (shared state, rate -//! limiters, the CSP/static/projects handlers) now live under -//! `crate::channels::web::platform::*`; this file re-exports them so the -//! existing `crate::channels::web::server::*` paths continue to resolve -//! while the ironclaw#2599 migration is in progress. +//! The platform-level pieces (shared state, rate limiters, CSP/static +//! serving, route composition, `start_server`) live under +//! `crate::channels::web::platform::*`. This file re-exports them so +//! existing `crate::channels::web::server::*` call sites continue to +//! resolve while the ironclaw#2599 migration is in progress. use std::convert::Infallible; -use std::net::SocketAddr; use std::sync::Arc; use axum::{ - Json, Router, - extract::{DefaultBodyLimit, Path, Query, State, WebSocketUpgrade}, - http::{StatusCode, header}, - middleware, + Json, + extract::{Path, Query, State, WebSocketUpgrade}, + http::StatusCode, response::{ IntoResponse, sse::{Event, KeepAlive, Sse}, }, - routing::{get, post, put}, }; use serde::Deserialize; use sha2::{Digest, Sha256}; -use tokio::sync::oneshot; use tokio_stream::StreamExt; -use tower_http::cors::{AllowHeaders, CorsLayer}; -use tower_http::set_header::SetResponseHeaderLayer; use uuid::Uuid; use axum::http::HeaderMap; use crate::channels::relay::DEFAULT_RELAY_NAME; -use crate::channels::web::auth::{ - AdminUser, AuthenticatedUser, CombinedAuthState, auth_middleware, -}; -use crate::channels::web::handlers::chat::chat_events_handler; -use crate::channels::web::handlers::engine::{ - engine_mission_detail_handler, engine_mission_fire_handler, engine_mission_pause_handler, - engine_mission_resume_handler, engine_missions_handler, engine_missions_summary_handler, - engine_project_detail_handler, engine_projects_handler, engine_projects_overview_handler, - engine_thread_detail_handler, engine_thread_events_handler, engine_thread_steps_handler, - engine_threads_handler, -}; -use crate::channels::web::handlers::frontend::{ - frontend_layout_handler, frontend_layout_update_handler, frontend_widget_file_handler, - frontend_widgets_handler, -}; -use crate::channels::web::handlers::jobs::{ - job_files_list_handler, job_files_read_handler, jobs_cancel_handler, jobs_detail_handler, - jobs_events_handler, jobs_list_handler, jobs_prompt_handler, jobs_restart_handler, - jobs_summary_handler, -}; -use crate::channels::web::handlers::llm::{ - llm_list_models_handler, llm_providers_handler, llm_test_connection_handler, -}; -use crate::channels::web::handlers::memory::{ - memory_list_handler, memory_read_handler, memory_search_handler, memory_tree_handler, - memory_write_handler, -}; -use crate::channels::web::handlers::routines::{ - routines_delete_handler, routines_detail_handler, routines_list_handler, - routines_summary_handler, routines_toggle_handler, routines_trigger_handler, -}; -use crate::channels::web::handlers::settings::{ - settings_delete_handler, settings_export_handler, settings_get_handler, - settings_import_handler, settings_list_handler, settings_set_handler, - settings_tools_list_handler, settings_tools_set_handler, -}; -use crate::channels::web::handlers::skills::{ - skills_install_handler, skills_list_handler, skills_remove_handler, skills_search_handler, -}; -use crate::channels::web::platform::static_files::{ - BASE_CSP_HEADER, admin_css_handler, admin_html_handler, admin_js_handler, css_handler, - favicon_handler, health_handler, i18n_app_handler, i18n_en_handler, i18n_index_handler, - i18n_ko_handler, i18n_zh_handler, index_handler, js_handler, project_file_handler, - project_index_handler, project_redirect_handler, theme_css_handler, theme_init_handler, -}; +use crate::channels::web::auth::{AdminUser, AuthenticatedUser}; use crate::channels::web::types::*; use crate::channels::web::util::{ build_turns_from_db_messages, collect_generated_images_from_tool_results, @@ -89,11 +39,12 @@ use crate::secrets::SecretConsumeResult; // --- Backward-compat re-exports for the ironclaw#2599 migration --- // -// The platform-level state types and rate limiters moved to -// `crate::channels::web::platform::state`. External callers (handlers, +// The platform-level state types, rate limiters, and `start_server` moved +// to `crate::channels::web::platform::*`. External callers (handlers, // integration tests, `src/main.rs`, `src/app.rs`) still reach them via -// `crate::channels::web::server::*`; re-export until the follow-up PR -// updates every call site. +// `crate::channels::web::server::*`; re-export until follow-up PRs update +// every call site. +pub use crate::channels::web::platform::router::start_server; pub(crate) use crate::channels::web::platform::state::rate_limit_key_from_headers; pub use crate::channels::web::platform::state::{ ActiveConfigSnapshot, FrontendCacheKey, FrontendHtmlCache, GatewayState, PerUserRateLimiter, @@ -110,454 +61,6 @@ fn redact_oauth_state_for_logs(state: &str) -> String { format!("sha256:{short_hash}:len={}", state.len()) } -/// Start the gateway HTTP server. -/// -/// Returns the actual bound `SocketAddr` (useful when binding to port 0). -pub async fn start_server( - addr: SocketAddr, - state: Arc, - auth: CombinedAuthState, -) -> Result { - let listener = tokio::net::TcpListener::bind(addr).await.map_err(|e| { - crate::error::ChannelError::StartupFailed { - name: "gateway".to_string(), - reason: format!("Failed to bind to {}: {}", addr, e), - } - })?; - let bound_addr = - listener - .local_addr() - .map_err(|e| crate::error::ChannelError::StartupFailed { - name: "gateway".to_string(), - reason: format!("Failed to get local addr: {}", e), - })?; - - // Public routes (no auth) - let public = Router::new() - .route("/api/health", get(health_handler)) - .route("/oauth/callback", get(oauth_callback_handler)) - .route( - "/oauth/slack/callback", - get(slack_relay_oauth_callback_handler), - ) - .route("/relay/events", post(relay_events_handler)) - .route( - "/api/webhooks/{path}", - post(crate::channels::web::handlers::webhooks::webhook_trigger_handler), - ) - // User-scoped webhook endpoint for multi-tenant isolation - .route( - "/api/webhooks/u/{user_id}/{path}", - post(crate::channels::web::handlers::webhooks::webhook_trigger_user_scoped_handler), - ) - // OAuth social login routes (public, no auth required) - .route( - "/auth/providers", - get(crate::channels::web::handlers::auth::providers_handler), - ) - .route( - "/auth/login/{provider}", - get(crate::channels::web::handlers::auth::login_handler), - ) - .route( - "/auth/callback/{provider}", - get(crate::channels::web::handlers::auth::callback_handler) - .post(crate::channels::web::handlers::auth::callback_post_handler), - ) - .route( - "/auth/logout", - post(crate::channels::web::handlers::auth::logout_handler), - ) - // NEAR wallet auth (challenge-response, not OAuth redirect) - .route( - "/auth/near/challenge", - get(crate::channels::web::handlers::auth::near_challenge_handler), - ) - .route( - "/auth/near/verify", - post(crate::channels::web::handlers::auth::near_verify_handler), - ); - - // Protected routes (require auth) - let auth_state = auth; - let protected = Router::new() - // Chat - .route("/api/chat/send", post(chat_send_handler)) - .route("/api/chat/gate/resolve", post(chat_gate_resolve_handler)) - .route("/api/chat/auth-token", post(chat_auth_token_handler)) - .route("/api/chat/auth-cancel", post(chat_auth_cancel_handler)) - .route("/api/chat/approval", post(chat_approval_handler)) - .route("/api/chat/events", get(chat_events_handler)) - .route("/api/chat/ws", get(chat_ws_handler)) - .route("/api/chat/history", get(chat_history_handler)) - .route("/api/chat/threads", get(chat_threads_handler)) - .route("/api/chat/thread/new", post(chat_new_thread_handler)) - // Memory - .route("/api/memory/tree", get(memory_tree_handler)) - .route("/api/memory/list", get(memory_list_handler)) - .route("/api/memory/read", get(memory_read_handler)) - .route("/api/memory/write", post(memory_write_handler)) - .route("/api/memory/search", post(memory_search_handler)) - // Jobs - .route("/api/jobs", get(jobs_list_handler)) - .route("/api/jobs/summary", get(jobs_summary_handler)) - .route("/api/jobs/{id}", get(jobs_detail_handler)) - .route("/api/jobs/{id}/cancel", post(jobs_cancel_handler)) - .route("/api/jobs/{id}/restart", post(jobs_restart_handler)) - .route("/api/jobs/{id}/prompt", post(jobs_prompt_handler)) - .route("/api/jobs/{id}/events", get(jobs_events_handler)) - .route("/api/jobs/{id}/files/list", get(job_files_list_handler)) - .route("/api/jobs/{id}/files/read", get(job_files_read_handler)) - // Logs - .route("/api/logs/events", get(logs_events_handler)) - .route("/api/logs/level", get(logs_level_get_handler)) - .route( - "/api/logs/level", - axum::routing::put(logs_level_set_handler), - ) - // Extensions - .route("/api/extensions", get(extensions_list_handler)) - .route( - "/api/extensions/readiness", - get(extensions_readiness_handler), - ) - .route("/api/extensions/tools", get(extensions_tools_handler)) - .route("/api/extensions/registry", get(extensions_registry_handler)) - .route("/api/extensions/install", post(extensions_install_handler)) - .route( - "/api/extensions/{name}/activate", - post(extensions_activate_handler), - ) - .route( - "/api/extensions/{name}/remove", - post(extensions_remove_handler), - ) - .route( - "/api/extensions/{name}/setup", - get(extensions_setup_handler).post(extensions_setup_submit_handler), - ) - // Pairing - .route("/api/pairing/{channel}", get(pairing_list_handler)) - .route( - "/api/pairing/{channel}/approve", - post(pairing_approve_handler), - ) - // Routines - .route("/api/routines", get(routines_list_handler)) - .route("/api/routines/summary", get(routines_summary_handler)) - .route("/api/routines/{id}", get(routines_detail_handler)) - .route("/api/routines/{id}/trigger", post(routines_trigger_handler)) - .route("/api/routines/{id}/toggle", post(routines_toggle_handler)) - .route( - "/api/routines/{id}", - axum::routing::delete(routines_delete_handler), - ) - .route("/api/routines/{id}/runs", get(routines_runs_handler)) - // Engine v2 - .route("/api/engine/threads", get(engine_threads_handler)) - .route( - "/api/engine/threads/{id}", - get(engine_thread_detail_handler), - ) - .route( - "/api/engine/threads/{id}/steps", - get(engine_thread_steps_handler), - ) - .route( - "/api/engine/threads/{id}/events", - get(engine_thread_events_handler), - ) - .route("/api/engine/projects", get(engine_projects_handler)) - .route( - "/api/engine/projects/overview", - get(engine_projects_overview_handler), - ) - .route( - "/api/engine/projects/{id}", - get(engine_project_detail_handler), - ) - .route( - "/api/engine/projects/{id}/widgets", - get(crate::channels::web::handlers::frontend::project_widgets_handler), - ) - .route("/api/engine/missions", get(engine_missions_handler)) - .route( - "/api/engine/missions/summary", - get(engine_missions_summary_handler), - ) - .route( - "/api/engine/missions/{id}", - get(engine_mission_detail_handler), - ) - .route( - "/api/engine/missions/{id}/fire", - post(engine_mission_fire_handler), - ) - .route( - "/api/engine/missions/{id}/pause", - post(engine_mission_pause_handler), - ) - .route( - "/api/engine/missions/{id}/resume", - post(engine_mission_resume_handler), - ) - // Skills - .route("/api/skills", get(skills_list_handler)) - .route("/api/skills/search", post(skills_search_handler)) - .route("/api/skills/install", post(skills_install_handler)) - .route( - "/api/skills/{name}", - axum::routing::delete(skills_remove_handler), - ) - // Settings - .route("/api/settings", get(settings_list_handler)) - .route("/api/settings/export", get(settings_export_handler)) - .route("/api/settings/import", post(settings_import_handler)) - // NOTE: These static routes intentionally shadow `/api/settings/{key}` when - // key="tools". Axum resolves static routes before parameterized ones, so this - // works correctly. Avoid adding a setting named literally "tools". - .route("/api/settings/tools", get(settings_tools_list_handler)) - .route( - "/api/settings/tools/{name}", - axum::routing::put(settings_tools_set_handler), - ) - .route("/api/settings/{key}", get(settings_get_handler)) - .route( - "/api/settings/{key}", - axum::routing::put(settings_set_handler), - ) - .route( - "/api/settings/{key}", - axum::routing::delete(settings_delete_handler), - ) - // LLM utilities - .route( - "/api/llm/test_connection", - post(llm_test_connection_handler), - ) - .route("/api/llm/list_models", post(llm_list_models_handler)) - .route("/api/llm/providers", get(llm_providers_handler)) - // User management (admin) - .route( - "/api/admin/users", - get(super::handlers::users::users_list_handler) - .post(super::handlers::users::users_create_handler), - ) - .route( - "/api/admin/users/{id}", - get(super::handlers::users::users_detail_handler) - .patch(super::handlers::users::users_update_handler) - .delete(super::handlers::users::users_delete_handler), - ) - .route( - "/api/admin/users/{id}/suspend", - post(super::handlers::users::users_suspend_handler), - ) - .route( - "/api/admin/users/{id}/activate", - post(super::handlers::users::users_activate_handler), - ) - // Admin secrets provisioning (per-user) - .route( - "/api/admin/users/{user_id}/secrets", - get(super::handlers::secrets::secrets_list_handler), - ) - .route( - "/api/admin/users/{user_id}/secrets/{name}", - put(super::handlers::secrets::secrets_put_handler) - .delete(super::handlers::secrets::secrets_delete_handler), - ) - // Admin tool policy - .route( - "/api/admin/tool-policy", - get(super::handlers::tool_policy::tool_policy_get_handler) - .put(super::handlers::tool_policy::tool_policy_put_handler), - ) - // Admin system prompt — tighter body cap than the global 10 MB so an - // oversized payload is rejected before being parsed into memory. - .route( - "/api/admin/system-prompt", - get(super::handlers::system_prompt::get_handler) - .put(super::handlers::system_prompt::put_handler) - .layer(DefaultBodyLimit::max(128 * 1024)), - ) - // Usage reporting (admin) - .route( - "/api/admin/usage", - get(super::handlers::users::usage_stats_handler), - ) - .route( - "/api/admin/usage/summary", - get(super::handlers::users::usage_summary_handler), - ) - // User self-service profile - .route( - "/api/profile", - get(super::handlers::users::profile_get_handler) - .patch(super::handlers::users::profile_update_handler), - ) - // Token management - .route( - "/api/tokens", - get(super::handlers::tokens::tokens_list_handler) - .post(super::handlers::tokens::tokens_create_handler), - ) - .route( - "/api/tokens/{id}", - axum::routing::delete(super::handlers::tokens::tokens_revoke_handler), - ) - // Frontend extension API - .route( - "/api/frontend/layout", - get(frontend_layout_handler).put(frontend_layout_update_handler), - ) - .route("/api/frontend/widgets", get(frontend_widgets_handler)) - .route( - "/api/frontend/widget/{id}/{*file}", - get(frontend_widget_file_handler), - ) - // Gateway control plane - .route("/api/gateway/status", get(gateway_status_handler)) - // OpenAI-compatible API - .route( - "/v1/chat/completions", - post(super::openai_compat::chat_completions_handler), - ) - .route("/v1/models", get(super::openai_compat::models_handler)) - // OpenAI Responses API (routes through the full agent loop) - .route( - "/v1/responses", - post(super::responses_api::create_response_handler), - ) - .route( - "/v1/responses/{id}", - get(super::responses_api::get_response_handler), - ) - .route_layer(middleware::from_fn_with_state( - auth_state.clone(), - auth_middleware, - )); - - // Static file routes (no auth, served from embedded strings) - let statics = Router::new() - .route("/", get(index_handler)) - .route("/theme.css", get(theme_css_handler)) - .route("/style.css", get(css_handler)) - .route("/app.js", get(js_handler)) - .route("/theme-init.js", get(theme_init_handler)) - .route("/favicon.ico", get(favicon_handler)) - .route("/i18n/index.js", get(i18n_index_handler)) - .route("/i18n/en.js", get(i18n_en_handler)) - .route("/i18n/zh-CN.js", get(i18n_zh_handler)) - .route("/i18n/ko.js", get(i18n_ko_handler)) - .route("/i18n-app.js", get(i18n_app_handler)) - // Admin panel SPA (auth handled client-side + API layer) - .route("/admin", get(admin_html_handler)) - .route("/admin/", get(admin_html_handler)) - .route("/admin/{*path}", get(admin_html_handler)) - .route("/admin.css", get(admin_css_handler)) - .route("/admin.js", get(admin_js_handler)); - - // Project file serving (behind auth to prevent unauthorized file access). - let projects = Router::new() - .route("/projects/{project_id}", get(project_redirect_handler)) - .route("/projects/{project_id}/", get(project_index_handler)) - .route("/projects/{project_id}/{*path}", get(project_file_handler)) - .route_layer(middleware::from_fn_with_state( - auth_state.clone(), - auth_middleware, - )); - - // CORS: restrict to same-origin by default. Only localhost/127.0.0.1 - // origins are allowed, since the gateway is a local-first service. - let cors = CorsLayer::new() - .allow_origin([ - format!("http://{}:{}", addr.ip(), addr.port()) - .parse() - .expect("valid origin"), - format!("http://localhost:{}", addr.port()) - .parse() - .expect("valid origin"), - ]) - .allow_methods([ - axum::http::Method::GET, - axum::http::Method::POST, - axum::http::Method::PUT, - axum::http::Method::PATCH, - axum::http::Method::DELETE, - ]) - .allow_headers(AllowHeaders::list([ - header::CONTENT_TYPE, - header::AUTHORIZATION, - ])) - .allow_credentials(true); - - let app = Router::new() - .merge(public) - .merge(statics) - .merge(projects) - .merge(protected) - .layer(DefaultBodyLimit::max(10 * 1024 * 1024)) // 10 MB max request body (image uploads) - .layer(tower_http::catch_panic::CatchPanicLayer::custom( - |panic_info: Box| { - let detail = if let Some(s) = panic_info.downcast_ref::() { - s.clone() - } else if let Some(s) = panic_info.downcast_ref::<&str>() { - (*s).to_string() - } else { - "unknown panic".to_string() - }; - // Truncate panic payload to avoid leaking sensitive data into logs. - // Use floor_char_boundary to avoid panicking on multi-byte UTF-8. - let safe_detail = if detail.len() > 200 { - let end = detail.floor_char_boundary(200); - format!("{}…", &detail[..end]) - } else { - detail - }; - tracing::error!("Handler panicked: {}", safe_detail); - axum::http::Response::builder() - .status(axum::http::StatusCode::INTERNAL_SERVER_ERROR) - .header("content-type", "text/plain") - .body(axum::body::Body::from("Internal Server Error")) - .unwrap_or_else(|_| { - axum::http::Response::new(axum::body::Body::from("Internal Server Error")) - }) - }, - )) - .layer(cors) - .layer(SetResponseHeaderLayer::if_not_present( - header::X_CONTENT_TYPE_OPTIONS, - header::HeaderValue::from_static("nosniff"), - )) - .layer(SetResponseHeaderLayer::if_not_present( - header::X_FRAME_OPTIONS, - header::HeaderValue::from_static("DENY"), - )) - .layer(SetResponseHeaderLayer::if_not_present( - header::HeaderName::from_static("content-security-policy"), - BASE_CSP_HEADER.clone(), - )) - .with_state(state.clone()); - - let (shutdown_tx, shutdown_rx) = oneshot::channel(); - *state.shutdown_tx.write().await = Some(shutdown_tx); - - tokio::spawn(async move { - if let Err(e) = axum::serve(listener, app) - .with_graceful_shutdown(async { - let _ = shutdown_rx.await; - tracing::debug!("Web gateway shutting down"); - }) - .await - { - tracing::error!("Web gateway server error: {}", e); - } - }); - - Ok(bound_addr) -} - /// Return an OAuth error landing page response. fn oauth_error_page(label: &str) -> axum::response::Response { let html = crate::auth::oauth::landing_html(label, false); @@ -573,7 +76,7 @@ fn oauth_error_page(label: &str) -> axum::response::Response { /// Used on hosted instances where `IRONCLAW_OAUTH_CALLBACK_URL` points to /// the gateway (e.g., `https://kind-deer.agent1.near.ai/oauth/callback`). /// Local/desktop mode continues to use the TCP listener on port 9876. -async fn oauth_callback_handler( +pub(crate) async fn oauth_callback_handler( State(state): State>, Query(params): Query>, ) -> impl IntoResponse { @@ -954,7 +457,7 @@ async fn oauth_callback_handler( /// Webhook endpoint for receiving relay events from channel-relay. /// /// PUBLIC route — authenticated via HMAC signature (X-Relay-Signature header). -async fn relay_events_handler( +pub(crate) async fn relay_events_handler( State(state): State>, headers: axum::http::HeaderMap, body: axum::body::Bytes, @@ -1048,7 +551,7 @@ async fn relay_events_handler( /// This is a PUBLIC route (no Bearer token required) because channel-relay /// redirects the user's browser here after Slack OAuth completes. /// Query params: `provider`, `team_id`. -async fn slack_relay_oauth_callback_handler( +pub(crate) async fn slack_relay_oauth_callback_handler( State(state): State>, headers: HeaderMap, Query(params): Query>, @@ -1322,7 +825,7 @@ fn mime_to_ext(mime: &str) -> &str { } } -async fn chat_send_handler( +pub(crate) async fn chat_send_handler( State(state): State>, AuthenticatedUser(user): AuthenticatedUser, headers: axum::http::HeaderMap, @@ -1401,7 +904,7 @@ async fn chat_send_handler( )) } -async fn chat_approval_handler( +pub(crate) async fn chat_approval_handler( State(state): State>, AuthenticatedUser(user): AuthenticatedUser, Json(req): Json, @@ -1471,7 +974,7 @@ async fn chat_approval_handler( )) } -async fn chat_gate_resolve_handler( +pub(crate) async fn chat_gate_resolve_handler( State(state): State>, AuthenticatedUser(user): AuthenticatedUser, Json(req): Json, @@ -1548,7 +1051,7 @@ async fn chat_gate_resolve_handler( } } -async fn chat_auth_token_handler( +pub(crate) async fn chat_auth_token_handler( State(state): State>, AuthenticatedUser(user): AuthenticatedUser, Json(req): Json, @@ -1737,7 +1240,7 @@ pub(crate) async fn handle_legacy_auth_token_submission( } } -async fn chat_auth_cancel_handler( +pub(crate) async fn chat_auth_cancel_handler( State(state): State>, AuthenticatedUser(user): AuthenticatedUser, Json(req): Json, @@ -1816,7 +1319,7 @@ fn is_local_origin(origin: &str) -> bool { matches!(host, "localhost" | "127.0.0.1" | "[::1]") } -async fn chat_ws_handler( +pub(crate) async fn chat_ws_handler( AuthenticatedUser(user): AuthenticatedUser, headers: axum::http::HeaderMap, ws: WebSocketUpgrade, @@ -1848,7 +1351,7 @@ async fn chat_ws_handler( } #[derive(Deserialize)] -struct HistoryQuery { +pub(crate) struct HistoryQuery { thread_id: Option, limit: Option, before: Option, @@ -2186,7 +1689,7 @@ fn reconcile_in_progress_with_turns( } } -async fn chat_history_handler( +pub(crate) async fn chat_history_handler( State(state): State>, AuthenticatedUser(user): AuthenticatedUser, Query(query): Query, @@ -2367,7 +1870,7 @@ fn summary_live_state(summary: &crate::history::ConversationSummary) -> Option>, AuthenticatedUser(user): AuthenticatedUser, ) -> Result, (StatusCode, String)> { @@ -2480,7 +1983,7 @@ async fn chat_threads_handler( })) } -async fn chat_new_thread_handler( +pub(crate) async fn chat_new_thread_handler( State(state): State>, AuthenticatedUser(user): AuthenticatedUser, ) -> Result, (StatusCode, String)> { @@ -2537,7 +2040,7 @@ async fn chat_new_thread_handler( // Job handlers moved to handlers/jobs.rs // --- Logs handlers --- -async fn logs_events_handler( +pub(crate) async fn logs_events_handler( State(state): State>, AuthenticatedUser(_user): AuthenticatedUser, ) -> Result { @@ -2575,7 +2078,7 @@ async fn logs_events_handler( )) } -async fn logs_level_get_handler( +pub(crate) async fn logs_level_get_handler( State(state): State>, AuthenticatedUser(_user): AuthenticatedUser, ) -> Result, (StatusCode, String)> { @@ -2586,7 +2089,7 @@ async fn logs_level_get_handler( Ok(Json(serde_json::json!({ "level": handle.current_level() }))) } -async fn logs_level_set_handler( +pub(crate) async fn logs_level_set_handler( State(state): State>, AuthenticatedUser(user): AuthenticatedUser, Json(body): Json, @@ -2611,7 +2114,7 @@ async fn logs_level_set_handler( // --- Extension handlers --- -async fn extensions_list_handler( +pub(crate) async fn extensions_list_handler( State(state): State>, AuthenticatedUser(user): AuthenticatedUser, ) -> Result, (StatusCode, String)> { @@ -2696,7 +2199,7 @@ fn extension_phase_for_web( } } -async fn extensions_readiness_handler( +pub(crate) async fn extensions_readiness_handler( State(state): State>, AuthenticatedUser(user): AuthenticatedUser, ) -> Result, (StatusCode, String)> { @@ -2737,7 +2240,7 @@ async fn extensions_readiness_handler( Ok(Json(ExtensionReadinessResponse { extensions })) } -async fn extensions_tools_handler( +pub(crate) async fn extensions_tools_handler( State(state): State>, AuthenticatedUser(_user): AuthenticatedUser, ) -> Result, (StatusCode, String)> { @@ -2758,7 +2261,7 @@ async fn extensions_tools_handler( Ok(Json(ToolListResponse { tools })) } -async fn extensions_install_handler( +pub(crate) async fn extensions_install_handler( State(state): State>, AuthenticatedUser(user): AuthenticatedUser, Json(req): Json, @@ -2870,7 +2373,7 @@ fn apply_extension_readiness_to_response( } } -async fn extensions_activate_handler( +pub(crate) async fn extensions_activate_handler( State(state): State>, AuthenticatedUser(user): AuthenticatedUser, Path(name): Path, @@ -2902,7 +2405,7 @@ async fn extensions_activate_handler( } } -async fn extensions_remove_handler( +pub(crate) async fn extensions_remove_handler( State(state): State>, AuthenticatedUser(user): AuthenticatedUser, Path(name): Path, @@ -2918,7 +2421,7 @@ async fn extensions_remove_handler( } } -async fn extensions_registry_handler( +pub(crate) async fn extensions_registry_handler( State(state): State>, AuthenticatedUser(user): AuthenticatedUser, Query(params): Query, @@ -2982,7 +2485,7 @@ async fn extensions_registry_handler( Json(RegistrySearchResponse { entries }) } -async fn extensions_setup_handler( +pub(crate) async fn extensions_setup_handler( State(state): State>, AuthenticatedUser(user): AuthenticatedUser, Path(name): Path, @@ -3015,7 +2518,7 @@ async fn extensions_setup_handler( })) } -async fn extensions_setup_submit_handler( +pub(crate) async fn extensions_setup_submit_handler( State(state): State>, AuthenticatedUser(user): AuthenticatedUser, Path(name): Path, @@ -3123,7 +2626,7 @@ async fn extensions_setup_submit_handler( // --- Pairing handlers --- -async fn pairing_list_handler( +pub(crate) async fn pairing_list_handler( State(state): State>, AdminUser(_user): AdminUser, Path(channel): Path, @@ -3160,7 +2663,7 @@ async fn pairing_list_handler( /// Approve a pairing code. Uses `AuthenticatedUser` (not `AdminUser`) because /// pairing is self-service: the user who received the code in their Telegram DM /// claims it for their own account. -async fn pairing_approve_handler( +pub(crate) async fn pairing_approve_handler( State(state): State>, AuthenticatedUser(user): AuthenticatedUser, Path(channel): Path, @@ -3272,7 +2775,7 @@ async fn pairing_approve_handler( Ok(Json(ActionResponse::ok("Pairing approved.".to_string()))) } -async fn routines_runs_handler( +pub(crate) async fn routines_runs_handler( State(state): State>, AuthenticatedUser(user): AuthenticatedUser, Path(id): Path, @@ -3323,7 +2826,7 @@ async fn routines_runs_handler( // --- Gateway control plane handlers --- -async fn gateway_status_handler( +pub(crate) async fn gateway_status_handler( State(state): State>, AuthenticatedUser(_user): AuthenticatedUser, ) -> Json { @@ -3401,7 +2904,7 @@ struct ModelUsageEntry { } #[derive(serde::Serialize)] -struct GatewayStatusResponse { +pub(crate) struct GatewayStatusResponse { version: String, #[serde(skip_serializing_if = "Option::is_none")] commit_hash: Option, @@ -3445,10 +2948,14 @@ mod tests { use super::*; use crate::agent::SessionManager; use crate::auth::oauth; - use crate::channels::web::auth::UserIdentity; + use crate::channels::web::auth::{CombinedAuthState, UserIdentity}; + use crate::channels::web::handlers::llm::{ + llm_list_models_handler, llm_test_connection_handler, + }; + use crate::channels::web::platform::router::start_server; use crate::channels::web::platform::static_files::{ BASE_CSP_HEADER, build_csp, build_csp_with_nonce, build_frontend_html, css_etag, - generate_csp_nonce, stamp_nonce_into_html, + css_handler, generate_csp_nonce, stamp_nonce_into_html, }; use crate::channels::web::sse::SseManager; use crate::channels::web::types::{ @@ -3459,6 +2966,9 @@ mod tests { use crate::testing::credentials::TEST_GATEWAY_CRYPTO_KEY; use crate::tools::{Tool, ToolError, ToolOutput, ToolRegistry}; use crate::workspace::Workspace; + use axum::Router; + use axum::http::header; + use axum::routing::{get, post}; use ironclaw_gateway::{NONCE_PLACEHOLDER, assets}; #[test] From 31735cc033454b9267bed8fcaacfb74716e2a79c Mon Sep 17 00:00:00 2001 From: Illia Polosukhin Date: Sat, 18 Apr 2026 13:27:09 +0000 Subject: [PATCH 2/2] =?UTF-8?q?fix(gateway):=20address=20PR=20#2643=20revi?= =?UTF-8?q?ew=20=E2=80=94=20proper=20error=20handling=20in=20router=20CORS?= =?UTF-8?q?=20+=20doc=20reconciliation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes for review comments on #2643: 1. **router.rs CORS origin parsing (comment #3104968733, CI `no-panics`):** Replaced `.expect("valid origin")` with `.map_err(|e| ChannelError::StartupFailed { .. })` so a malformed bound address fails the bootstrap with a semantically specific error instead of panicking. Also switched from `format!("http://{}:{}", addr.ip(), addr.port())` to `format!("http://{addr}")` — `SocketAddr`'s `Display` brackets IPv6 addresses correctly (`[::1]:8080` rather than the ambiguous `::1:8080`), so the CORS origin stays valid on v6 binds. Fixes the `No panics in production code` CI check failure and the `Code Style` composite check that depends on it. 2. **platform/mod.rs docstring (comment #3104970223):** The previous "feature handlers depend on platform, not the other way around" wording conflicted with `router` importing feature handlers. Rewrote to name `router` as the single, intentional exception to the no-back-edges rule, and explicitly list the platform submodules the rule still applies to (`state`, `static_files`, future `auth`/`sse`/`ws`). 3. **CLAUDE.md layering section (comment #3104970232):** Same contradiction — rewrote to reconcile: router is the coupling point; every other platform submodule must stay handler-agnostic; the forthcoming CI check (ironclaw#2599 stage 5) enforces forbidden imports between `platform/{state,static_files,auth,sse,ws}.rs` and `handlers/*` / `features/*` while explicitly allowing `platform/router.rs` to reference both sides. Verified: `python3 scripts/check_no_panics.py` clean; `cargo clippy --all --benches --tests --examples --all-features` clean; `cargo test --lib channels::web::server::tests::test_` passes 30 server-module tests including CORS / CSP / start_server smoke coverage. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/channels/web/CLAUDE.md | 29 ++++++++++++++++++++--------- src/channels/web/platform/mod.rs | 15 ++++++++++++--- src/channels/web/platform/router.rs | 28 ++++++++++++++++++++-------- 3 files changed, 52 insertions(+), 20 deletions(-) diff --git a/src/channels/web/CLAUDE.md b/src/channels/web/CLAUDE.md index 31d40fe6dd3..1e18514ce5f 100644 --- a/src/channels/web/CLAUDE.md +++ b/src/channels/web/CLAUDE.md @@ -23,15 +23,26 @@ Browser-facing HTTP API and SSE/WebSocket real-time streaming. Axum-based, singl ## Platform vs. feature layering (ironclaw#2599) -The target layout is a `platform/` subtree (router, state, auth, SSE, WS, -static serving) that feature handlers depend on, with no back-edges. The -flat `handlers/` folder is a transitional fallback — individual handlers -will migrate into `features//` directories once their platform -dependencies are narrowed to a per-slice `Deps` view. When adding a new -platform-level concern, put it under `platform/`; when adding a new -feature handler, keep it under `handlers/` for now but design it so the -surface it consumes from `GatewayState` is a narrow subset that can -later be replaced by a typed `Deps` alias. +The target layout is a `platform/` subtree (router, state, auth, SSE, +WS, static serving) that feature handlers depend on. + +**The "no back-edges" rule has one intentional exception: the router.** +Route composition is inherently the coupling point where transport +meets features — `platform/router.rs` imports every feature handler it +registers. Every *other* platform submodule (state, static_files, and +the auth/SSE/WS modules once they move) must stay handler-agnostic, +and that's what the future CI check (ironclaw#2599 stage 5) will +enforce: forbid cross-imports between `platform/{state,static_files, +auth,sse,ws}.rs` and `handlers/*` / `features/*`, but allow +`platform/router.rs` to reference both sides. + +The flat `handlers/` folder is a transitional fallback — individual +handlers will migrate into `features//` directories once their +platform dependencies are narrowed to a per-slice `Deps` view. When +adding a new platform-level concern, put it under `platform/`; when +adding a new feature handler, keep it under `handlers/` for now but +design it so the surface it consumes from `GatewayState` is a narrow +subset that can later be replaced by a typed `Deps` alias. ## API Routes diff --git a/src/channels/web/platform/mod.rs b/src/channels/web/platform/mod.rs index f68ee9a3954..00b42a14fb4 100644 --- a/src/channels/web/platform/mod.rs +++ b/src/channels/web/platform/mod.rs @@ -2,9 +2,18 @@ //! //! This submodule holds the gateway's transport and framing concerns: shared //! state, the Axum route composition, static asset serving, and (in later -//! stages of ironclaw#2599) auth / SSE / WS. Feature-specific handlers live -//! alongside their domain (`handlers/` today, `features//` in later -//! stages) and depend on the platform layer, not the other way around. +//! stages of ironclaw#2599) auth / SSE / WS. +//! +//! **Dependency direction.** Feature handlers (under `handlers/` today, +//! `features//` later) depend on platform types (`GatewayState`, +//! rate limiters, auth extractors). Platform *submodules* do **not** +//! reach back into feature handlers — with the single, intentional +//! exception of [`router`], which is the composition point. The router +//! imports every feature handler it registers; that is its job. The +//! "no back-edges" rule enforced by future CI (ironclaw#2599 stage 5) +//! applies to `platform/state.rs`, `platform/static_files.rs`, and the +//! auth/SSE/WS modules once they move here — not to `router`, whose +//! whole purpose is to wire features onto the transport. //! //! See `src/channels/web/CLAUDE.md` for the staged migration plan. diff --git a/src/channels/web/platform/router.rs b/src/channels/web/platform/router.rs index 67f4de11f92..c39e063b987 100644 --- a/src/channels/web/platform/router.rs +++ b/src/channels/web/platform/router.rs @@ -448,15 +448,27 @@ pub async fn start_server( // CORS: restrict to same-origin by default. Only localhost/127.0.0.1 // origins are allowed, since the gateway is a local-first service. + // + // `SocketAddr`'s `Display` handles IPv6 bracketing correctly + // (`[::1]:8080` rather than `::1:8080`), so building the origin off the + // whole `addr` avoids a broken URL on v6 binds. Parse errors here would + // mean the `SocketAddr` itself produced an invalid HTTP origin — a + // startup bug, not a request-time error — so we fail the bootstrap + // with `ChannelError::StartupFailed` rather than panic. + let ip_origin = format!("http://{addr}").parse().map_err(|e| { + crate::error::ChannelError::StartupFailed { + name: "gateway".to_string(), + reason: format!("Invalid CORS origin for bound addr {addr}: {e}"), + } + })?; + let localhost_origin = format!("http://localhost:{}", addr.port()) + .parse() + .map_err(|e| crate::error::ChannelError::StartupFailed { + name: "gateway".to_string(), + reason: format!("Invalid CORS origin for localhost:{}: {e}", addr.port()), + })?; let cors = CorsLayer::new() - .allow_origin([ - format!("http://{}:{}", addr.ip(), addr.port()) - .parse() - .expect("valid origin"), - format!("http://localhost:{}", addr.port()) - .parse() - .expect("valid origin"), - ]) + .allow_origin([ip_origin, localhost_origin]) .allow_methods([ axum::http::Method::GET, axum::http::Method::POST,