//! `HttpAdapter` — `ProtocolHandler` for `h2`/`http/1.1` (axum over a //! `BiStream`). //! //! Wires the axum `Router` (gateway endpoints + `/healthz` + //! `/openapi.json` + MCP + custom routes + decoy fallback) and drives //! hyper's HTTP/1.1 or HTTP/2 connection driver over a single //! bidirectional stream yielded by `Connection::accept_bi()`. The WS //! upgrade route lands with the websocket subsystem; until then the //! router reserves `/alk/channels` for it. //! //! ## Reserved paths (ADR-046 §3) //! //! [`RESERVED_PATHS`] is enforced per-method at `build_router` time: a //! custom route that registers *any* method on a reserved path panics //! at construction. The default surface already registers its own //! methods on these paths, so axum's merge would catch the same-method //! case anyway; the pre-merge probe extends the guarantee to the //! per-method case (e.g. a custom `POST /search` next to the default //! `GET /search`), where a silent merge would otherwise serve a custom //! handler on a reserved path and break the "default surface wins" //! rule. Same-path-different-method merges outside the reserved set //! remain legal (axum composes the `MethodRouter`). //! //! ## Connection knobs and boundaries //! //! The accept-loop side (`serve_io`, crate-private) configures the hyper auto builder with a //! tokio timer and timeouts (values in the method doc). The **concurrency //! cap is not a knob of this crate**: the `ProtocolHandler::handle` trait method serves //! exactly one accepted bidirectional stream per call, and the accept //! loop — how many streams are handled concurrently, on which tasks — //! belongs to the consumer that owns the `Connection` (or the //! assembly-layer listener). Timeout/cap interaction: a request can //! hold its connection for the whole handler runtime; only the header //! phase and idle keep-alive windows are bounded here. use std::sync::Arc; use std::time::Duration; use alkcall::core::auth::AuthContext; use alkcall::core::types::{Connection, HandlerError, StreamError}; use alkcall::registry::registration::OperationRegistry; use async_trait::async_trait; use axum::routing::get; use axum::Router; use hyper_util::rt::{TokioExecutor, TokioIo, TokioTimer}; use hyper_util::server::conn::auto::Builder as HyperBuilder; use hyper_util::service::TowerToHyperService; use parking_lot::Mutex; use tracing::error; use super::auth::bearer_auth_middleware; use super::decoy::{decoy_fallback, decoy_method_not_allowed}; use super::healthz::healthz; use super::state::{DecoyConfig, RouterState}; /// The HTTP/1.1 ALPN (`http/1.1`) `HttpAdapter` registers on (ADR-001). pub const ALPN_HTTP1: &[u8] = b"http/1.1"; /// The HTTP/2 ALPN (`h2`) `HttpAdapter` registers on (ADR-001). pub const ALPN_H2: &[u8] = b"h2"; /// The WS upgrade path (ADR-067). Reserved in the default surface; the /// handler is wired by the websocket subsystem task. pub const WS_UPGRADE_PATH: &str = "/alk/channels"; /// Reserved default-surface paths (ADR-046 collision rule). Custom /// routes must not register any method on these paths — the default /// surface owns them. Enforced per-method in `build_router` (a /// same-method collision would already panic in axum's merge; the /// pre-merge probe also covers different-method registrations, which /// would otherwise silently merge in). pub const RESERVED_PATHS: &[&str] = &[ "/search", "/schema", "/call", "/batch", "/subscribe", "/publish", "/healthz", "/openapi.json", "/mcp", WS_UPGRADE_PATH, ]; /// The HTTP server host (ADR-001, ADR-002, ADR-039): an axum /// `Router` serving the gateway, `/healthz`, `/openapi.json`, the MCP /// route, the WS upgrade path, and assembly-registered custom routes, /// behind the bearer-auth middleware — served over one ALPN depending /// on the constructor. pub struct HttpAdapter { identity_provider: Arc, registry: Arc, decoy: DecoyConfig, extra_routes: Option, alpn: &'static [u8], router: Router, openapi_doc: CachedOpenAPIDoc, ws_sessions: Arc, ws_max_sessions: usize, ws_session_slots: Arc, ws_idle_timeout: Option, } impl HttpAdapter { /// An HTTP/1.1 adapter (registers on `http/1.1` ALPN). pub fn new( identity_provider: Arc, registry: Arc, ) -> Self { Self::for_alpn(identity_provider, registry, ALPN_HTTP1) } /// An HTTP/2 adapter (registers on `h2` ALPN). pub fn h2( identity_provider: Arc, registry: Arc, ) -> Self { Self::for_alpn(identity_provider, registry, ALPN_H2) } fn for_alpn( identity_provider: Arc, registry: Arc, alpn: &'static [u8], ) -> Self { let decoy = DecoyConfig::default(); let openapi_doc = CachedOpenAPIDoc::new(®istry); let ws_sessions = Arc::new(crate::websocket::WsSessions::new()); let ws_max_sessions = crate::websocket::DEFAULT_WS_MAX_SESSIONS; let ws_session_slots = Arc::new(tokio::sync::Semaphore::new(ws_max_sessions)); let ws_idle_timeout = Some(crate::websocket::DEFAULT_WS_IDLE_TIMEOUT); let state = RouterState { registry: Arc::clone(®istry), identity_provider: Arc::clone(&identity_provider), decoy: decoy.clone(), openapi_doc: openapi_doc.clone(), ws_sessions: Arc::clone(&ws_sessions), ws_session_slots: Arc::clone(&ws_session_slots), ws_idle_timeout, publish_schemas: crate::gateway::schema_cache::PublishSchemaCache::new(), }; let router = build_router(state, None); Self { identity_provider, registry, decoy, extra_routes: None, alpn, router, openapi_doc, ws_sessions, ws_max_sessions, ws_session_slots, ws_idle_timeout, } } /// Set the decoy surface for unregistered paths and rebuild the /// router (custom routes are preserved — SRV-05). pub fn with_decoy(mut self, decoy: DecoyConfig) -> Self { self.decoy = decoy.clone(); let state = RouterState { registry: Arc::clone(&self.registry), identity_provider: Arc::clone(&self.identity_provider), decoy, openapi_doc: self.openapi_doc.clone(), ws_sessions: Arc::clone(&self.ws_sessions), ws_session_slots: Arc::clone(&self.ws_session_slots), ws_idle_timeout: self.ws_idle_timeout, publish_schemas: crate::gateway::schema_cache::PublishSchemaCache::new(), }; // `extra_routes` is borrowed, not consumed (SRV-05): a builder // call after `with_extra_routes` must keep the custom routes in // the rebuild — `with_extra_routes` stores a verified clone, so // re-merging it is safe. self.router = build_router(state, self.extra_routes.clone()); self } /// Mount assembly-provided custom routes under the bearer-auth /// middleware (ADR-046) and rebuild the router. Reserved paths /// (`RESERVED_PATHS`) are rejected before the merge. pub fn with_extra_routes(mut self, routes: Router) -> Self { let state = RouterState { registry: Arc::clone(&self.registry), identity_provider: Arc::clone(&self.identity_provider), decoy: self.decoy.clone(), openapi_doc: self.openapi_doc.clone(), ws_sessions: Arc::clone(&self.ws_sessions), ws_session_slots: Arc::clone(&self.ws_session_slots), ws_idle_timeout: self.ws_idle_timeout, publish_schemas: crate::gateway::schema_cache::PublishSchemaCache::new(), }; self.router = build_router(state, Some(routes.clone())); self.extra_routes = Some(routes); self } /// The concurrent WS session cap (WS-09): the upgrade handler /// acquires one semaphore permit per upgrade, post-auth and /// pre-upgrade; a caller over the configured cap is rejected with /// **503 Service Unavailable**, and the permit is held for the /// session's lifetime (an ended session frees its slot). /// /// Default: [`crate::websocket::DEFAULT_WS_MAX_SESSIONS`] (64). pub fn with_ws_max_sessions(mut self, max_sessions: usize) -> Self { self.ws_max_sessions = max_sessions; let state = RouterState { registry: Arc::clone(&self.registry), identity_provider: Arc::clone(&self.identity_provider), decoy: self.decoy.clone(), openapi_doc: self.openapi_doc.clone(), ws_sessions: Arc::clone(&self.ws_sessions), ws_session_slots: Self::rebuild_session_slots(max_sessions), ws_idle_timeout: self.ws_idle_timeout, publish_schemas: crate::gateway::schema_cache::PublishSchemaCache::new(), }; self.router = build_router(state, self.extra_routes.clone()); self } /// The WS idle-read timeout (WS-01, WS-13 semantics): the read /// pump closes the connection with a 1001 (GoingAway) close frame /// after this long producing **no completed inbound chunk** — the /// deadline resets on demux progress (complete chunks forwarded /// into the byte stream), not on WS message arrival, so a /// dribbling peer (slow message arrivals inside a declared chunk) /// is bounded while productive-but-slow peers survive. This is an /// intentional no-progress eviction line, not a transport-idle /// bound: there is no WS ping/pong keepalive, and app-silence that /// outlasts the window (a quiet subscription) is evicted by design /// — see `websocket::byte_adapter`'s module doc. `None` disables /// the knob (not recommended: the stall window is then unbounded; /// long-lived silent subscriptions are the intended `None` case, /// leaning on `WsSessions::abort` and the write-side caps). /// /// Default: [`crate::websocket::DEFAULT_WS_IDLE_TIMEOUT`] (60 s). pub fn with_ws_idle_timeout(mut self, idle_timeout: Option) -> Self { self.ws_idle_timeout = idle_timeout; let state = RouterState { registry: Arc::clone(&self.registry), identity_provider: Arc::clone(&self.identity_provider), decoy: self.decoy.clone(), openapi_doc: self.openapi_doc.clone(), ws_sessions: Arc::clone(&self.ws_sessions), ws_session_slots: Arc::clone(&self.ws_session_slots), ws_idle_timeout: self.ws_idle_timeout, publish_schemas: crate::gateway::schema_cache::PublishSchemaCache::new(), }; self.router = build_router(state, self.extra_routes.clone()); self } /// The shared WS session registry (WS-08): live sessions' /// [`WsPumps`](crate::websocket::WsPumps) handles, evictable via /// `WsSessions::abort`. The upgrade handler registers against this /// instance. pub fn ws_sessions(&self) -> Arc { Arc::clone(&self.ws_sessions) } /// A fresh semaphore for the new cap; the retained handles of /// already-open sessions are unaffected (they hold their permits). fn rebuild_session_slots(max_sessions: usize) -> Arc { Arc::new(tokio::sync::Semaphore::new(max_sessions)) } /// The configured decoy surface (assembly introspection). pub fn decoy(&self) -> &DecoyConfig { &self.decoy } /// The ALPN this adapter registers on (`http/1.1` or `h2`). pub fn alpn(&self) -> &'static [u8] { self.alpn } /// The assembled router (for the accept loop the consumer owns). pub fn router(&self) -> &Router { &self.router } } fn build_router(state: RouterState, extra_routes: Option) -> Router { let auth_state = Arc::clone(&state.identity_provider); #[cfg(feature = "mcp")] let mcp_router: Router = { let dispatch = crate::gateway::GatewayDispatch::new( Arc::clone(&state.registry), Arc::clone(&state.identity_provider), ); Router::new() .nest_service( "/mcp", crate::adapters::to_mcp_service(std::sync::Arc::new(dispatch)), ) .layer(axum::middleware::from_fn(mcp_body_limit)) .layer(from_fn_with_state( auth_state.clone(), bearer_auth_middleware, )) }; #[cfg(not(feature = "mcp"))] let mcp_router: Router = Router::new(); let default: Router = Router::new() .merge(crate::gateway::routes::gateway_router()) // The openapi handler's state is the pre-serialized projection // cache (SRV-09), threaded through `RouterState` below. .route("/openapi.json", get(openapi_json_handler)) .route("/healthz", get(healthz)) .fallback(decoy_fallback) .method_not_allowed_fallback(decoy_method_not_allowed); let with_extras = match extra_routes { Some(extra) => { enforce_reserved_paths(&extra); let extra: Router = extra.with_state(()); default.merge(extra) } None => default, }; // Re-applied after the extras merge (SRV-12): the call covers only // the MethodRouters registered before it, so without this the extra // routes keep axum's bare 405 (no decoy body, no `Server: nginx`) — // the exact stealth probe SRV-07 neutralized for the default // surface. Idempotent for the routers the earlier call covered. let with_extras = with_extras.method_not_allowed_fallback(decoy_method_not_allowed); // Applied after the merges (ADR-046 §4): the bearer-auth layer wraps // every route registered before this call (the gateway endpoints, // /openapi.json, /healthz, and the extra routes) — axum 0.8 // semantics are that `route_layer` wraps the routes registered // before it, not the ones after. Routes that must not pass through // this layer (the /mcp nest and the WS upgrade route, each with its // own auth) are merged/registered after it — SRV-11: before the // reorder, a route carrying an inner auth layer was also wrapped by // this one and resolved the token twice (the SRV-10 double-resolve, // the old comment claiming the opposite). let with_auth = with_extras.route_layer(from_fn_with_state( Arc::clone(&auth_state), bearer_auth_middleware, )); // Merged after the router-wide route_layer on purpose: the /mcp // nest carries its own bearer layer (applied around the nested // service, `from_fn_with_state` above), so registering it here keeps // exactly one token resolution per request (SRV-11). nest_service // registers a plain Route endpoint (no MethodRouter), so the decoy // 405 fallback has no interplay with this merge. let with_mcp = with_auth.merge(mcp_router); // Registered after the router-wide route_layer on purpose: axum's // route_layer applies only to earlier-registered routes, so the WS // upgrade path resolves the token exactly once through its own // `ws_bearer_auth` (401 without a resolvable token — a WS session // without an identity cannot run AccessControl::check). The // MethodRouter carries the decoy 405 fallback explicitly // (MethodRouter::route_layer wraps method endpoints, not the // fallback) — a wrong-method probe on /alk/channels keeps the decoy // shape instead of axum's bare 405. with_mcp .route( WS_UPGRADE_PATH, get(crate::websocket::ws_upgrade_handler) .fallback(decoy_method_not_allowed) .route_layer(from_fn_with_state( Arc::clone(&auth_state), crate::websocket::ws_bearer_auth, )), ) .with_state(state) } /// Enforce the per-method reserved-path collision rule (ADR-046 §3): /// reject extra routes that register any method on a /// [`RESERVED_PATHS`] path by merging a probe `MethodRouter` occupied /// on every method. The merge panics on the first collision — the same /// panic axum raises for a same-method overlap — and is a no-op for a /// custom router that respects the reserved set. fn enforce_reserved_paths(extra: &Router) { if !extra.has_routes() { return; } let probe = RESERVED_PATHS.iter().fold(Router::new(), |router, path| { router.route( path, get(rejected_reserved_path) .post(rejected_reserved_path) .put(rejected_reserved_path) .patch(rejected_reserved_path) .delete(rejected_reserved_path) .head(rejected_reserved_path) .options(rejected_reserved_path) .trace(rejected_reserved_path) .connect(rejected_reserved_path), ) }); let _ = Router::new().merge(probe).merge(extra.clone()); } async fn rejected_reserved_path() -> axum::response::Response { unreachable!("reserved-path probe handler is never called") } use axum::middleware::from_fn_with_state; /// Cap the `/mcp` body at 8 MiB (feature `mcp`). /// /// The nested rmcp `StreamableHttpService` collects the raw request body /// itself (`expect_json` → `body.collect()`), bypassing axum's /// extractor-based default limit: `DefaultBodyLimit` works by inserting /// an extension that `FromRequest` extractors consult, so it has no /// effect on a service that reads the body directly (rmcp 1.8 /// `server_side_http::expect_json` never checks it). This middleware is /// both the cap and the status source: it wraps the body in a counting /// stream that stops at [`MCP_BODY_LIMIT`] with an explicit error and /// post-checks a exceedance flag to answer `413 Payload Too Large`, /// replacing whatever the inner service answered (rmcp maps body-read /// errors to `500`). /// /// The limit is 8 MiB — headroom over the gateway's 2 MiB whole-body /// default for JSON-RPC batch payloads on the MCP surface. #[cfg(feature = "mcp")] const MCP_BODY_LIMIT: usize = 8 * 1024 * 1024; #[cfg(feature = "mcp")] const MCP_BODY_LIMIT_EXCEEDED: &str = "mcp body limit exceeded"; #[cfg(feature = "mcp")] async fn mcp_body_limit( req: axum::extract::Request, next: axum::middleware::Next, ) -> axum::response::Response { use axum::response::IntoResponse; let (parts, body) = req.into_parts(); if let Some(len) = parts .headers .get(http::header::CONTENT_LENGTH) .and_then(|v| v.to_str().ok()) .and_then(|v| v.parse::().ok()) { if len > MCP_BODY_LIMIT { return ( axum::http::StatusCode::PAYLOAD_TOO_LARGE, "Payload Too Large: /mcp body exceeds the 8 MiB limit", ) .into_response(); } } let exceeded = Arc::new(std::sync::atomic::AtomicBool::new(false)); let counting = CountingBody { inner: body.into_data_stream(), remaining: MCP_BODY_LIMIT, exceeded: Arc::clone(&exceeded), }; let mut limited_req = axum::extract::Request::from_parts(parts, axum::body::Body::from_stream(counting)); limited_req.extensions_mut().insert(exceeded.clone()); let response = next.run(limited_req).await; if exceeded.load(std::sync::atomic::Ordering::Relaxed) { return ( axum::http::StatusCode::PAYLOAD_TOO_LARGE, "Payload Too Large: /mcp body exceeds the 8 MiB limit", ) .into_response(); } response } #[cfg(feature = "mcp")] struct CountingBody { inner: axum::body::BodyDataStream, remaining: usize, exceeded: Arc, } #[cfg(feature = "mcp")] impl futures::Stream for CountingBody { type Item = Result; fn poll_next( mut self: std::pin::Pin<&mut Self>, cx: &mut std::task::Context<'_>, ) -> std::task::Poll> { let this = &mut *self; match std::pin::Pin::new(&mut this.inner).poll_next(cx) { std::task::Poll::Ready(Some(Ok(data))) => { let len = data.len(); if len > this.remaining { this.remaining = 0; this.exceeded .store(true, std::sync::atomic::Ordering::Relaxed); return std::task::Poll::Ready(Some(Err(std::io::Error::other( MCP_BODY_LIMIT_EXCEEDED, )))); } this.remaining -= len; std::task::Poll::Ready(Some(Ok(data))) } std::task::Poll::Ready(Some(Err(e))) => { let _ = e; std::task::Poll::Ready(Some(Err(std::io::Error::other(MCP_BODY_LIMIT_EXCEEDED)))) } std::task::Poll::Pending => std::task::Poll::Pending, std::task::Poll::Ready(None) => std::task::Poll::Ready(None), } } } #[async_trait] impl alkcall::core::types::ProtocolHandler for HttpAdapter { fn alpn(&self) -> &'static [u8] { self.alpn } async fn handle(&self, connection: Connection, auth: &AuthContext) -> Result<(), HandlerError> { if let Some(identity) = auth.identity.clone() { let _ = connection.set_identity(identity); } let stream = connection .accept_bi() .await .map_err(stream_error_to_handler)?; self.serve_io(stream).await } } impl HttpAdapter { /// Serve one accepted bidirectional stream as a single HTTP /// connection. /// /// Hyper knobs (SRV-04) — hyper 1.11 *silently ignores* the /// header-read timeout unless a timer is set, so the timer is /// explicit and the values chosen are: /// /// - `header_read_timeout` = **10 s** (tighter than hyper's 30 s /// default; bounds the slow-loris window where a client drips /// request header bytes) /// - h1 `keep_alive` = **enabled** (default; normal client reuse), /// with hyper's default 30 s idle header-read window applying /// per-request /// - h2 `keep_alive_interval` = **30 s**, `keep_alive_timeout` = /// **10 s** — a peer that fails to ack pings for 10 s is dropped, /// so half-open h2 connections do not accumulate /// /// No concurrency cap is set here — see the module doc: the accept /// loop is the consumer's, and the per-connection "cap" is one /// stream per `handle` call. async fn serve_io(&self, io: I) -> Result<(), HandlerError> where I: tokio::io::AsyncRead + tokio::io::AsyncWrite + Send + Unpin + 'static, { let io = TokioIo::new(io); let service = TowerToHyperService::new(self.router.clone()); const HEADER_READ_TIMEOUT: Duration = Duration::from_secs(10); const H2_KEEP_ALIVE_INTERVAL: Duration = Duration::from_secs(30); const H2_KEEP_ALIVE_TIMEOUT: Duration = Duration::from_secs(10); #[cfg_attr(not(feature = "h2"), allow(unused_mut))] let mut builder = HyperBuilder::new(TokioExecutor::new()); #[cfg(feature = "http1")] { builder .http1() .timer(TokioTimer::new()) .keep_alive(true) .header_read_timeout(HEADER_READ_TIMEOUT); } #[cfg(feature = "h2")] { builder .http2() .timer(TokioTimer::new()) .enable_connect_protocol() .keep_alive_interval(H2_KEEP_ALIVE_INTERVAL) .keep_alive_timeout(H2_KEEP_ALIVE_TIMEOUT); } let conn = builder.serve_connection_with_upgrades(io, service); tokio::pin!(conn); let result = (&mut conn).await; if let Err(e) = result { error!("http adapter: connection closed with error: {e}"); } Ok(()) } } fn stream_error_to_handler(e: StreamError) -> HandlerError { HandlerError::from(e) } /// Serialized-to-bytes cache of the `/openapi.json` projection /// (SRV-09): built once per `HttpAdapter` from the registry at /// construction instead of re-projecting + re-serializing per request. /// The registry is defined not to mutate after assembly (the assembly /// layer registers handlers before serving), so the doc is fixed for /// the adapter's lifetime; a rebuild via [`HttpAdapter::with_decoy`] / /// `with_extra_routes` re-derives it from the same registry. /// /// Serialization happens once, here — so a serde failure surfaces at /// construction as a tracing error and the handler answers `/openapi.json` /// with a generic 500 (no serde internals on the wire) rather than /// echoing an error to unauthenticated callers. #[derive(Clone)] pub(crate) struct CachedOpenAPIDoc { inner: Arc>>, } struct CachedOpenAPIDocInner { bytes: axum::body::Bytes, } impl CachedOpenAPIDoc { pub(crate) fn new(registry: &OperationRegistry) -> Self { Self { inner: Arc::new(Mutex::new(None)), } .with_registry(registry) } fn with_registry(self, registry: &OperationRegistry) -> Self { let spec = match crate::adapters::to_openapi(registry) { Ok(spec) => spec, Err(e) => { error!("openapi.json projection failed; endpoint will return 500: {e}"); return self; } }; match serde_json::to_vec(&spec.raw) { Ok(bytes) => { *self.inner.lock() = Some(CachedOpenAPIDocInner { bytes: axum::body::Bytes::from(bytes), }); } Err(e) => { error!("openapi.json serialization failed; endpoint will return 500: {e}"); } } self } fn bytes(&self) -> Option { self.inner.lock().as_ref().map(|c| c.bytes.clone()) } } /// `GET /openapi.json` — the `to_openapi` projection of the local /// operation registry (ADR-042, ADR-045): the fixed 6-endpoint gateway /// doc. Served under the bearer-auth route layer like every other /// gateway endpoint. The serialized doc is cached at adapter /// construction ([`CachedOpenAPIDoc`]); a cache miss (projection or /// serialization failed at construction) answers with a **generic** /// `500` body — no serde internals on the wire. async fn openapi_json_handler( axum::extract::State(doc): axum::extract::State, ) -> axum::response::Response { use axum::response::IntoResponse; match doc.bytes() { Some(bytes) => ([(http::header::CONTENT_TYPE, "application/json")], bytes).into_response(), None => ( axum::http::StatusCode::INTERNAL_SERVER_ERROR, "internal server error", ) .into_response(), } } #[cfg(test)] mod tests { use super::*; use crate::server::auth::ResolvedIdentity; use alkcall::core::auth::IdentityProvider; use alkcall::core::types::ProtocolHandler; use tokio::io::{AsyncReadExt, AsyncWriteExt}; struct NoopProvider; impl IdentityProvider for NoopProvider { fn resolve_from_fingerprint(&self, _: &str) -> Option { None } fn resolve_from_token( &self, _: &alkcall::core::auth::AuthToken, ) -> Option { None } } fn empty_registry() -> Arc { Arc::new(OperationRegistry::new()) } fn provider() -> Arc { Arc::new(NoopProvider) } #[test] fn alpn_returns_http1_for_default_new() { let adapter = HttpAdapter::new(provider(), empty_registry()); assert_eq!(adapter.alpn(), ALPN_HTTP1); assert_eq!(adapter.alpn(), b"http/1.1"); } #[test] fn alpn_returns_h2_for_h2_constructor() { let adapter = HttpAdapter::h2(provider(), empty_registry()); assert_eq!(adapter.alpn(), ALPN_H2); assert_eq!(adapter.alpn(), b"h2"); } #[test] fn decoy_config_default_is_not_found() { assert!(matches!(DecoyConfig::default(), DecoyConfig::NotFound)); } #[test] fn with_decoy_updates_decoy() { let adapter = HttpAdapter::new(provider(), empty_registry()); let adapter = adapter.with_decoy(DecoyConfig::Redirect { to: "https://example.com".to_string(), }); assert!(matches!(adapter.decoy(), DecoyConfig::Redirect { .. })); } #[tokio::test] async fn full_http_request_response_cycle_over_duplex() { let extra = Router::new().route("/v1/ping", get(|| async { "pong" })); let adapter = HttpAdapter::new(provider(), empty_registry()).with_extra_routes(extra); let (client, server) = tokio::io::duplex(64 * 1024); let conn = Connection::from_bidi(server, b"http/1.1".to_vec(), None); let auth = AuthContext::anonymous(b"http/1.1"); let server_task = tokio::spawn(async move { ProtocolHandler::handle(&adapter, conn, &auth).await }); let mut client = client; client .write_all(b"GET /v1/ping HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n") .await .unwrap(); let mut response = Vec::new(); let _ = tokio::time::timeout( std::time::Duration::from_secs(5), client.read_to_end(&mut response), ) .await .expect("read timed out") .unwrap(); let text = String::from_utf8_lossy(&response); assert!(text.starts_with("HTTP/1.1 200 OK"), "got: {text}"); assert!(text.contains("pong"), "got: {text}"); let _ = server_task.await; } #[tokio::test] async fn healthz_served_by_the_adapter_over_duplex() { let adapter = HttpAdapter::new(provider(), empty_registry()); let (client, server) = tokio::io::duplex(64 * 1024); let conn = Connection::from_bidi(server, b"http/1.1".to_vec(), None); let auth = AuthContext::anonymous(b"http/1.1"); let server_task = tokio::spawn(async move { let _ = ProtocolHandler::handle(&adapter, conn, &auth).await; }); let mut client = client; client .write_all(b"GET /healthz HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n") .await .unwrap(); let mut response = Vec::new(); let _ = tokio::time::timeout( std::time::Duration::from_secs(5), client.read_to_end(&mut response), ) .await .expect("read timed out") .unwrap(); let text = String::from_utf8_lossy(&response); assert!(text.starts_with("HTTP/1.1 200 OK"), "got: {text}"); assert!(text.contains("ok"), "got: {text}"); let _ = server_task.await; } #[tokio::test] async fn unknown_path_serves_decoy_404_over_duplex() { let adapter = HttpAdapter::new(provider(), empty_registry()); let (client, server) = tokio::io::duplex(64 * 1024); let conn = Connection::from_bidi(server, b"http/1.1".to_vec(), None); let auth = AuthContext::anonymous(b"http/1.1"); let server_task = tokio::spawn(async move { let _ = ProtocolHandler::handle(&adapter, conn, &auth).await; }); let mut client = client; client .write_all(b"GET /nowhere HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n") .await .unwrap(); let mut response = Vec::new(); let _ = tokio::time::timeout( std::time::Duration::from_secs(5), client.read_to_end(&mut response), ) .await .expect("read timed out") .unwrap(); let text = String::from_utf8_lossy(&response); assert!(text.starts_with("HTTP/1.1 404 Not Found"), "got: {text}"); assert!( text.contains("nginx"), "decoy should look like nginx: {text}" ); let _ = server_task.await; } #[tokio::test] async fn openapi_json_serves_the_gateway_projection() { use tokio::io::{AsyncReadExt, AsyncWriteExt}; let mut registry = OperationRegistry::new(); let spec = alkcall::registry::spec::OperationSpec::new( "echo/run", alkcall::registry::spec::OperationType::Query, alkcall::registry::spec::Visibility::External, serde_json::json!({}), serde_json::json!({}), vec![], alkcall::registry::spec::AccessControl::default(), None, ); registry .register(alkcall::registry::registration::HandlerRegistration::new( spec, alkcall::registry::registration::HandlerKind::Once( alkcall::registry::registration::make_handler(|input, ctx| async move { alkcall::protocol::wire::ResponseEnvelope::ok(ctx.request_id, input) }), ), alkcall::registry::registration::OperationProvenance::Local, None, None, alkcall::core::types::Capabilities::new(), )) .unwrap(); let adapter = HttpAdapter::new(provider(), Arc::new(registry)); let (client, server) = tokio::io::duplex(256 * 1024); let conn = Connection::from_bidi(server, b"http/1.1".to_vec(), None); let auth = AuthContext::anonymous(b"http/1.1"); let server_task = tokio::spawn(async move { let _ = ProtocolHandler::handle(&adapter, conn, &auth).await; }); let mut client = client; client .write_all( b"GET /openapi.json HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n", ) .await .unwrap(); let mut response = Vec::new(); let _ = tokio::time::timeout( std::time::Duration::from_secs(5), client.read_to_end(&mut response), ) .await .expect("read timed out") .unwrap(); let text = String::from_utf8_lossy(&response); assert!(text.starts_with("HTTP/1.1 200 OK"), "got: {text}"); assert!(text.contains("application/json"), "got: {text}"); // The 6-endpoint gateway doc; the version tracks the projection // truthfulness pass. assert!(text.contains("\"/publish\""), "publish path in doc"); assert!(text.contains("1.3.0"), "info.version 1.3.0 in doc"); assert!(text.contains("gatewayPublish"), "publish operationId"); let _ = server_task.await; } #[cfg(feature = "mcp")] #[tokio::test] async fn mcp_endpoint_serves_four_gateway_tools_bearer_gated() { use tokio::io::{AsyncReadExt, AsyncWriteExt}; let adapter = HttpAdapter::new(provider(), empty_registry()); let (client, server) = tokio::io::duplex(256 * 1024); let conn = Connection::from_bidi(server, b"http/1.1".to_vec(), None); let auth = AuthContext::anonymous(b"http/1.1"); let server_task = tokio::spawn(async move { let _ = ProtocolHandler::handle(&adapter, conn, &auth).await; }); let mut client = client; client .write_all( b"POST /mcp HTTP/1.1\r\nHost: localhost\r\nContent-Type: application/json\r\nAccept: application/json, text/event-stream\r\nContent-Length: 175\r\nConnection: close\r\n\r\n{\"jsonrpc\": \"2.0\", \"id\": 1, \"method\": \"initialize\", \"params\": {\"protocolVersion\": \"2025-06-18\", \"capabilities\": {}, \"clientInfo\": {\"name\": \"test-client\", \"version\": \"1.0.0\"}}}", ) .await .unwrap(); let mut response = Vec::new(); let _ = tokio::time::timeout( std::time::Duration::from_secs(5), client.read_to_end(&mut response), ) .await .expect("read timed out") .unwrap(); let text = String::from_utf8_lossy(&response); // Bearer middleware is applied around the nested service: no token // means no identity stash, but the middleware does not enforce — // the MCP initialize response must still come back. assert!( text.starts_with("HTTP/1.1 200 OK"), "initialize over /mcp got: {text}" ); assert!( text.contains("alkhttp-to-mcp"), "server info in initialize response: {text}" ); let _ = server_task.await; } #[cfg(feature = "mcp")] #[tokio::test] async fn mcp_rejects_oversized_body_declared_content_length_with_413() { use tokio::io::{AsyncReadExt, AsyncWriteExt}; let oversized = MCP_BODY_LIMIT + 1; let head = format!( "POST /mcp HTTP/1.1\r\nHost: localhost\r\nContent-Type: application/json\r\nAccept: application/json, text/event-stream\r\nContent-Length: {oversized}\r\nConnection: close\r\n\r\n" ); let adapter = HttpAdapter::new(provider(), empty_registry()); let (client, server) = tokio::io::duplex(64 * 1024); let conn = Connection::from_bidi(server, b"http/1.1".to_vec(), None); let auth = AuthContext::anonymous(b"http/1.1"); let server_task = tokio::spawn(async move { let _ = ProtocolHandler::handle(&adapter, conn, &auth).await; }); let mut client = client; client.write_all(head.as_bytes()).await.unwrap(); let mut response = Vec::new(); let _ = tokio::time::timeout( std::time::Duration::from_secs(5), client.read_to_end(&mut response), ) .await .expect("read timed out") .unwrap(); let text = String::from_utf8_lossy(&response); assert!( text.starts_with("HTTP/1.1 413 Payload Too Large"), "declared oversized body got: {text}" ); let _ = server_task.await; } #[cfg(feature = "mcp")] #[tokio::test] async fn mcp_rejects_oversized_chunked_body_with_413() { use tokio::io::{AsyncReadExt, AsyncWriteExt}; let adapter = HttpAdapter::new(provider(), empty_registry()); let (client, server) = tokio::io::duplex(64 * 1024); let conn = Connection::from_bidi(server, b"http/1.1".to_vec(), None); let auth = AuthContext::anonymous(b"http/1.1"); let server_task = tokio::spawn(async move { let _ = ProtocolHandler::handle(&adapter, conn, &auth).await; }); let (mut reader_client, mut writer_client) = tokio::io::split(client); writer_client .write_all( b"POST /mcp HTTP/1.1\r\nHost: localhost\r\nContent-Type: application/json\r\nAccept: application/json, text/event-stream\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n", ) .await .unwrap(); let writer = tokio::spawn(async move { let chunk = vec![b'a'; 64 * 1024]; let chunk_header = format!("{:x}\r\n", chunk.len()); for _ in 0..(MCP_BODY_LIMIT / chunk.len()) + 1 { if writer_client .write_all(chunk_header.as_bytes()) .await .is_err() { return; } if writer_client.write_all(&chunk).await.is_err() { return; } if writer_client.write_all(b"\r\n").await.is_err() { return; } } let _ = writer_client.write_all(b"0\r\n\r\n").await; }); let mut response = Vec::new(); let read = tokio::time::timeout( std::time::Duration::from_secs(10), reader_client.read_to_end(&mut response), ) .await; writer.abort(); read.expect("read timed out").unwrap(); let text = String::from_utf8_lossy(&response); assert!( text.starts_with("HTTP/1.1 413 Payload Too Large"), "chunked oversized body got: {text}" ); let _ = server_task.await; } struct StaticProvider; impl IdentityProvider for StaticProvider { fn resolve_from_fingerprint(&self, _: &str) -> Option { None } fn resolve_from_token( &self, _: &alkcall::core::auth::AuthToken, ) -> Option { Some(alkcall::core::auth::Identity { id: "worker-a".to_string(), scopes: vec![], resources: std::collections::HashMap::new(), }) } } fn static_provider() -> Arc { Arc::new(StaticProvider) } struct CountingProvider { resolutions: std::sync::Mutex, } impl CountingProvider { fn new() -> Self { Self { resolutions: std::sync::Mutex::new(0), } } fn resolutions(&self) -> usize { *self.resolutions.lock().unwrap_or_else(|e| e.into_inner()) } } impl IdentityProvider for CountingProvider { fn resolve_from_fingerprint(&self, _: &str) -> Option { None } fn resolve_from_token( &self, _: &alkcall::core::auth::AuthToken, ) -> Option { *self.resolutions.lock().unwrap_or_else(|e| e.into_inner()) += 1; Some(alkcall::core::auth::Identity { id: "worker-a".to_string(), scopes: vec![], resources: std::collections::HashMap::new(), }) } } async fn ws_upgrade_oneshot( app: Router, authorization: &str, ) -> axum::http::Response { use tower::ServiceExt; let request = axum::http::Request::builder() .method(axum::http::Method::GET) .uri(WS_UPGRADE_PATH) .header(axum::http::header::AUTHORIZATION, authorization) .header(axum::http::header::CONNECTION, "upgrade") .header(axum::http::header::UPGRADE, "websocket") .header(axum::http::header::SEC_WEBSOCKET_VERSION, "13") .header( axum::http::header::SEC_WEBSOCKET_KEY, "dGhlIHNhbXBsZSBub25jZQ==", ) .header( axum::http::header::HOST, axum::http::HeaderValue::from_static("localhost"), ); let mut request = request.body(axum::body::Body::empty()).unwrap(); let on_upgrade = hyper::upgrade::on(&mut request); request.extensions_mut().insert(on_upgrade); app.oneshot(request).await.unwrap() } fn router_state(idp: Arc) -> RouterState { RouterState { registry: empty_registry(), identity_provider: idp, decoy: DecoyConfig::default(), openapi_doc: CachedOpenAPIDoc::new(&OperationRegistry::new()), ws_sessions: Arc::new(crate::websocket::WsSessions::new()), ws_session_slots: Arc::new(tokio::sync::Semaphore::new( crate::websocket::DEFAULT_WS_MAX_SESSIONS, )), ws_idle_timeout: Some(crate::websocket::DEFAULT_WS_IDLE_TIMEOUT), publish_schemas: crate::gateway::schema_cache::PublishSchemaCache::new(), } } async fn get_with_bearer( app: Router, path: &str, authorization: Option<&str>, ) -> axum::http::Response { use tower::ServiceExt; let mut builder = axum::http::Request::builder().uri(path); if let Some(value) = authorization { builder = builder.header(axum::http::header::AUTHORIZATION, value); } app.oneshot(builder.body(axum::body::Body::empty()).unwrap()) .await .unwrap() } async fn get_with_bearer_with_method( app: Router, request: axum::http::Request, ) -> axum::http::Response { use tower::ServiceExt; app.oneshot(request).await.unwrap() } #[tokio::test] async fn extra_routes_resolve_bearer_identity_through_the_default_auth() { let extra = Router::new().route( "/v1/whoami", get(|ResolvedIdentity(identity): ResolvedIdentity| async move { match identity { Some(id) => id.id, None => "none".to_string(), } }), ); let app = build_router(router_state(static_provider()), Some(extra)); let response = get_with_bearer(app.clone(), "/v1/whoami", Some("Bearer alk_test")).await; assert_eq!(response.status(), axum::http::StatusCode::OK); let body = axum::body::to_bytes(response.into_body(), usize::MAX) .await .unwrap(); assert_eq!(&body[..], b"worker-a"); let response = get_with_bearer(app, "/v1/whoami", None).await; assert_eq!(response.status(), axum::http::StatusCode::OK); let body = axum::body::to_bytes(response.into_body(), usize::MAX) .await .unwrap(); assert_eq!(&body[..], b"none"); } #[tokio::test] async fn extra_route_with_own_layer_can_opt_out_of_default_auth() { async fn public_identity( mut request: axum::extract::Request, next: axum::middleware::Next, ) -> axum::response::Response { request .extensions_mut() .insert(Some(alkcall::core::auth::Identity { id: "public-webhook".to_string(), scopes: vec![], resources: std::collections::HashMap::new(), })); next.run(request).await } let extra = Router::new().route( "/v1/public", get(|ResolvedIdentity(identity): ResolvedIdentity| async move { match identity { Some(id) => id.id, None => "none".to_string(), } }) .route_layer(axum::middleware::from_fn(public_identity)), ); let app = build_router(router_state(static_provider()), Some(extra)); let response = get_with_bearer(app, "/v1/public", None).await; assert_eq!(response.status(), axum::http::StatusCode::OK); let body = axum::body::to_bytes(response.into_body(), usize::MAX) .await .unwrap(); assert_eq!(&body[..], b"public-webhook"); } #[tokio::test] #[should_panic(expected = "Overlapping method route")] async fn extra_route_on_reserved_path_panics_at_construction() { let extra = Router::new().route( "/search", axum::routing::post(|| async { "shadowing the gateway" }), ); let _ = HttpAdapter::new(static_provider(), empty_registry()).with_extra_routes(extra); } #[tokio::test] async fn extra_route_on_non_reserved_path_merges_cleanly() { let extra = Router::new().route( "/v1/ping", get(|| async { "pong" }).post(|| async { "pong-post" }), ); let adapter = HttpAdapter::new(static_provider(), empty_registry()).with_extra_routes(extra); assert!(adapter.router().has_routes()); } #[tokio::test] async fn second_with_decoy_keeps_extra_routes() { let extra = Router::new().route("/v1/ping", get(|| async { "pong" })); let adapter = HttpAdapter::new(static_provider(), empty_registry()) .with_extra_routes(extra) .with_decoy(DecoyConfig::Redirect { to: "https://example.com".to_string(), }); let request = axum::http::Request::builder() .uri("/v1/ping") .body(axum::body::Body::empty()) .unwrap(); let response = get_with_bearer(adapter.router().clone(), "/v1/ping", None).await; drop(request); assert_eq!( response.status(), axum::http::StatusCode::OK, "a second builder call must not drop the extra routes (SRV-05)" ); } #[tokio::test] async fn method_mismatch_on_registered_path_serves_decoy_405() { let adapter = HttpAdapter::new(static_provider(), empty_registry()); let request = axum::http::Request::builder() .method(axum::http::Method::OPTIONS) .uri("/search") .body(axum::body::Body::empty()) .unwrap(); let response = get_with_bearer_with_method(adapter.router().clone(), request).await; assert_eq!( response.status(), axum::http::StatusCode::METHOD_NOT_ALLOWED ); let server = response .headers() .get(axum::http::header::SERVER) .map(|v| v.to_str().unwrap().to_string()); assert_eq!( server.as_deref(), Some("nginx"), "405 must carry the decoy Server header, not axum's bare 405 (SRV-07)" ); let body = axum::body::to_bytes(response.into_body(), usize::MAX) .await .unwrap(); let body = String::from_utf8_lossy(&body); assert!(body.contains("405 Not Allowed"), "got: {body}"); assert!( !body.contains("axum") && !body.contains("alk"), "got: {body}" ); } #[tokio::test] async fn method_mismatch_on_extra_route_serves_decoy_405() { let extra = Router::new().route("/v1/ping", get(|| async { "pong" })); let adapter = HttpAdapter::new(static_provider(), empty_registry()).with_extra_routes(extra); let request = axum::http::Request::builder() .method(axum::http::Method::DELETE) .uri("/v1/ping") .body(axum::body::Body::empty()) .unwrap(); let response = get_with_bearer_with_method(adapter.router().clone(), request).await; assert_eq!( response.status(), axum::http::StatusCode::METHOD_NOT_ALLOWED, "wrong-method probe on an extra route" ); let server = response .headers() .get(axum::http::header::SERVER) .map(|v| v.to_str().unwrap().to_string()); assert_eq!( server.as_deref(), Some("nginx"), "extra-route 405 must carry the decoy Server header, not axum's bare 405 (SRV-12)" ); let body = axum::body::to_bytes(response.into_body(), usize::MAX) .await .unwrap(); let body = String::from_utf8_lossy(&body); assert!(body.contains("405 Not Allowed"), "got: {body}"); assert!( !body.contains("axum") && !body.contains("alk"), "got: {body}" ); } #[tokio::test] async fn method_mismatch_on_default_surface_still_serves_decoy_405_after_extras_merge() { let extra = Router::new().route("/v1/ping", get(|| async { "pong" })); let adapter = HttpAdapter::new(static_provider(), empty_registry()).with_extra_routes(extra); let request = axum::http::Request::builder() .method(axum::http::Method::OPTIONS) .uri("/search") .body(axum::body::Body::empty()) .unwrap(); let response = get_with_bearer_with_method(adapter.router().clone(), request).await; assert_eq!( response.status(), axum::http::StatusCode::METHOD_NOT_ALLOWED ); let server = response .headers() .get(axum::http::header::SERVER) .map(|v| v.to_str().unwrap().to_string()); assert_eq!( server.as_deref(), Some("nginx"), "the re-applied 405 fallback must not regress the default surface (SRV-07)" ); let body = axum::body::to_bytes(response.into_body(), usize::MAX) .await .unwrap(); let body = String::from_utf8_lossy(&body); assert!(body.contains("405 Not Allowed"), "got: {body}"); } #[tokio::test] async fn ws_upgrade_resolves_the_token_exactly_once() { let provider = Arc::new(CountingProvider::new()); let app = build_router( router_state(provider.clone() as Arc), None, ); let response = ws_upgrade_oneshot(app, "Bearer alk_test").await; assert_eq!( response.status(), axum::http::StatusCode::SWITCHING_PROTOCOLS, "a valid bearer token upgrades" ); assert_eq!( provider.resolutions(), 1, "the WS upgrade path must resolve the token exactly once (SRV-11)" ); let provider = Arc::new(CountingProvider::new()); let app = build_router( router_state(provider.clone() as Arc), None, ); let response = ws_upgrade_oneshot(app, "Bearer alk_test").await; drop(response); assert_eq!( provider.resolutions(), 1, "exactly one resolution per upgrade request after any builder order" ); } #[tokio::test] async fn ws_upgrade_without_token_is_rejected_401() { let app = build_router(router_state(static_provider()), None); let response = ws_upgrade_oneshot(app, "").await; assert_eq!( response.status(), axum::http::StatusCode::UNAUTHORIZED, "the WS route keeps its own enforced layer" ); } #[cfg(feature = "mcp")] #[tokio::test] async fn mcp_request_resolves_the_token_exactly_once() { let provider = Arc::new(CountingProvider::new()); let app = build_router( router_state(provider.clone() as Arc), None, ); let request = axum::http::Request::builder() .method(axum::http::Method::POST) .uri("/mcp") .header(axum::http::header::HOST, "localhost") .header(axum::http::header::AUTHORIZATION, "Bearer alk_test") .header(axum::http::header::CONTENT_TYPE, "application/json") .header( axum::http::header::ACCEPT, "application/json, text/event-stream", ) .body(axum::body::Body::from( serde_json::to_vec(&serde_json::json!({ "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": { "protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": { "name": "test-client", "version": "1.0.0" } } })) .unwrap(), )) .unwrap(); let response = tower::ServiceExt::oneshot(app, request).await.unwrap(); assert_eq!( response.status(), axum::http::StatusCode::OK, "the /mcp initialize response comes back" ); let body = axum::body::to_bytes(response.into_body(), usize::MAX) .await .unwrap(); assert!( String::from_utf8_lossy(&body).contains("alkhttp-to-mcp"), "initialize response body, got: {}", String::from_utf8_lossy(&body) ); assert_eq!( provider.resolutions(), 1, "the /mcp path must resolve the token exactly once (SRV-11)" ); } #[tokio::test] async fn method_mismatch_on_ws_upgrade_path_serves_decoy_405() { let app = build_router(router_state(static_provider()), None); let request = axum::http::Request::builder() .method(axum::http::Method::POST) .uri(WS_UPGRADE_PATH) .body(axum::body::Body::empty()) .unwrap(); let response = get_with_bearer_with_method(app, request).await; assert_eq!( response.status(), axum::http::StatusCode::METHOD_NOT_ALLOWED ); let server = response .headers() .get(axum::http::header::SERVER) .map(|v| v.to_str().unwrap().to_string()); assert_eq!( server.as_deref(), Some("nginx"), "wrong-method probe on the WS path keeps the decoy shape after the reorder" ); } #[tokio::test] async fn openapi_json_is_cached_and_generic_on_cache_miss() { let adapter = HttpAdapter::new(static_provider(), empty_registry()); let first = openapi_json_handler(axum::extract::State(adapter.openapi_doc.clone())).await; let second = openapi_json_handler(axum::extract::State(adapter.openapi_doc.clone())).await; let first_bytes = axum::body::to_bytes(first.into_body(), usize::MAX) .await .unwrap(); let second_bytes = axum::body::to_bytes(second.into_body(), usize::MAX) .await .unwrap(); assert_eq!( &first_bytes[..], &second_bytes[..], "cached doc is byte-stable" ); let miss = CachedOpenAPIDoc { inner: Arc::new(Mutex::new(None)), }; let response = openapi_json_handler(axum::extract::State(miss)).await; assert_eq!( response.status(), axum::http::StatusCode::INTERNAL_SERVER_ERROR ); let body = axum::body::to_bytes(response.into_body(), usize::MAX) .await .unwrap(); assert_eq!( &body[..], b"internal server error", "the 500 body is the fixed generic string, no serde internals" ); } }