--- status: draft last_updated: 2026-06-30 --- # HTTP Server The `HttpAdapter` — the `ProtocolHandler` for `h2` and `http/1.1` (and WebSocket upgrade — see §"WebSocket browser path"). The `h3`/WebTransport path is deferred per [ADR-044](../../decisions/044-defer-webtransport-browsers-use-websocket.md); the deferred spec is at [webtransport.md](webtransport.md). This document covers how axum is run over a QUIC bidirectional stream, Bearer auth resolution, the HTTP-to-call dispatch, the `/healthz` raw route, stealth decoy, and the WebSocket browser path. ## What The `HttpAdapter` is constructed by the assembly layer with an `Arc` (constructor injection, same pattern as `SshAdapter` — see [auth.md](../core/auth.md)) and an `Arc` (for dispatching HTTP requests to call-protocol operations). It implements `ProtocolHandler` for the standard HTTP ALPNs. ```rust pub struct HttpAdapter { identity_provider: Arc, registry: Arc, /// The default handler for paths that are not registered operations /// (stealth decoy). Configurable: a static site, a fake 404, a /// redirect. Two-way-door default (ADR-010). decoy: DecoyConfig, } /// The stealth decoy surface for paths that are not registered /// operations (and not `/healthz`, `/openapi.json`, the `to_openapi` /// gateway endpoints `/search`/`/schema`/`/call`/`/batch`/`/subscribe`, /// or the MCP route). Set by the assembly layer at `HttpAdapter` /// construction. The existence of the decoy path is fixed by ADR-010; /// the variant is a two-way-door config default. pub enum DecoyConfig { /// Serve a fake `404 Not Found` (the default — matches the reference /// implementation's "fake nginx 404"). NotFound, /// Serve a static site from a configured directory (the directory /// path is the payload). For deployments that want a real decoy /// website. StaticSite { root: PathBuf }, /// Redirect to a configured URL. Redirect { to: String }, } #[async_trait] impl ProtocolHandler for HttpAdapter { fn alpn(&self) -> &'static [u8]; // returns the configured ALPN async fn handle(&self, connection: Connection, auth: &AuthContext) -> Result<(), HandlerError>; } ``` The `HttpAdapter` registers for multiple ALPNs (`http/1.1`, `h2`). The endpoint's `HandlerRegistry` maps each ALPN byte string to the same adapter instance; `handle()` branches on `connection.remote_alpn()` to pick the HTTP framing. For `http/1.1` and `h2`, the framing is hyper's HTTP/1.1 or HTTP/2 over a QUIC bidirectional stream. WebSocket upgrade (§"WebSocket browser path") layers on top of the same hyper connection driver — a WS upgrade is an HTTP/1.1 or HTTP/2 request that switches protocols. The `h3` ALPN is deferred (ADR-044); the deferred handler design is at [webtransport.md](webtransport.md). ## Why HTTP is the standard external interface. Browsers, curl, axios, API gateways, and load balancers all speak HTTP. Serving HTTP on the standard ALPNs means any HTTP client can connect without knowing about alknet — the TLS handshake negotiates `h2` or `http/1.1` normally. This is the stealth mapping (ADR-010): the HTTP surface is the decoy for clients that don't offer alknet ALPNs, and the real external API surface for clients that do know about alknet. ## Architecture ### Running axum over a QUIC stream The `HttpAdapter::handle()` method for `h2`/`http/1.1`: 1. Accepts one bidirectional stream from the QUIC connection (`connection.accept_bi()` → `(SendStream, RecvStream)`). 2. Wraps the `(SendStream, RecvStream)` pair as a hyper `TokioIo`-compatible duplex stream — the same byte stream hyper expects for an HTTP connection. 3. Constructs the axum `Router` (built once at adapter construction, cloned per connection — axum `Router` is `Clone` and cheap to clone). 4. Hands the duplex stream + the axum router to hyper's connection driver (`hyper::server::conn::http1::Builder` or `http2::Builder::serve_connection`), which reads HTTP frames, parses them, dispatches to axum routes, and writes HTTP responses. 5. Returns when the HTTP connection closes (the client disconnects or the stream ends). The axum `Router` is built once at adapter construction with the `Arc` and `Arc` embedded in its state; cloning the `Router` per connection clones the `Arc`s (cheap, shared state), so every request handler has access to the registry and identity provider through the router's state. The axum `Router` is the single routing surface for HTTP requests. It contains: - **The direct-call surface** (`POST /{service}/{op}` → `call.requested` dispatch — ADR-036). This is the HTTP projection of the call protocol's `/{service}/{op}` operation path; an HTTP client that knows the operation name calls it directly. - **The `to_openapi` gateway endpoints** (`/search`, `/schema`, `/call`, `/batch`, `/subscribe` — ADR-042). These are the fixed 5-endpoint gateway that an OpenAPI consumer uses to discover and invoke operations without knowing operation names up front. `/call` and `/subscribe` dispatch through the same `OperationRegistry::invoke()` as the direct-call surface; `/search` and `/schema` dispatch the `services/list` / `services/schema` discovery ops. The gateway and the direct-call surface coexist on the same router — they are two projections of the same operation registry, not two registries. - `GET /healthz` (raw route, no auth, no call protocol). - `GET /openapi.json` (serves the `to_openapi` projection — the OpenAPI document that *describes* the 5 gateway endpoints. Post-ADR-042 this is the gateway's description doc, not a per-operation REST spec; the doc describes the 5 fixed endpoints, and the per-caller operation surface is discovered via `/search`, not preloaded into `paths`). - The stealth decoy fallback (unknown paths). - (Feature-gated) `POST /mcp` (the `to_mcp` streamable HTTP service — [http-mcp.md](http-mcp.md)). A single HTTP/2 or HTTP/1.1 connection multiplexes multiple requests over the one bidirectional stream (HTTP/2 multiplexing is native; HTTP/1.1 is sequential). The axum router handles each request on a tokio task; the hyper driver manages the connection lifetime. ### HTTP-to-call dispatch (ADR-036) An HTTP request at `POST /fs/readFile` (or `GET /services/list`, or any `/{service}/{op}` path matching a registered `External` operation) is dispatched to the call protocol: 1. The axum route handler extracts the operation name from the path (`/fs/readFile` → `fs/readFile`, stripping the leading slash — the registry form). 2. It resolves the caller's identity from the `Authorization: Bearer` header via `identity_provider.resolve_from_token(&AuthToken { raw: token_bytes })`. 3. It parses the request body as the operation input (JSON). 4. It constructs the root `OperationContext` (caller identity, the registration bundle's capabilities, the connection's env composition) and dispatches through the `OperationRegistry::invoke()` — the same dispatch path the `CallAdapter` uses for `alknet/call` wire requests. 5. The response (`ResponseEnvelope`) is serialized as the HTTP response body (JSON). Errors map to HTTP status codes (see Error Mapping below). `Internal` operations (ADR-015) return `404` on the HTTP handler, matching the call protocol's `NOT_FOUND` for wire calls to Internal ops — the HTTP handler dispatches only `External` operations. ### Streaming projection (SSE) A `Subscription` operation served over `h2`/`http/1.1` projects its `call.responded` stream as Server-Sent Events. The axum route handler: - Sets `Content-Type: text/event-stream`. - For each `call.responded` event, writes an SSE `data:` frame (the event's `output` serialized as JSON). - On `call.completed`, closes the SSE stream (normal end). - On `call.aborted`, closes the stream with an SSE error event. - On HTTP client disconnect (detected as the response writer closing), sends `call.aborted` for the in-flight subscription, which cascades to descendants per ADR-016. This is the HTTP/1.1 + HTTP/2 streaming projection. Over WebSocket (§"WebSocket browser path" below), the subscription projects directly onto the WS connection — `call.responded` events as binary WS messages, no SSE framing. WebTransport (`h3`, deferred per ADR-044) would project onto WebTransport bidirectional streams; see [webtransport.md](webtransport.md). ### One-directional projection (HTTP request/response) The HTTP/1.1 + HTTP/2 surface is a **lossy, one-directional projection** of the call protocol. HTTP is request/response: the client initiates, the server responds. The call protocol is bidirectional — both sides can initiate calls (see [../call/call-protocol.md](../call/call-protocol.md) §"Bidirectional Calls": the server can call operations on the client just as the client calls operations on the server). The HTTP projection carries only the client→server call direction; the server→client call direction has no HTTP expression (there is no HTTP mechanism for the server to initiate a request to the client). `Subscription` streaming is the one partial exception — the server streams `call.responded` frames back over the SSE response — but even there, the *call* is client-initiated; only the *results* flow server→client. This is a structural property of HTTP, not a design choice in this crate. **WebSocket restores the bidirectional call model for browsers** (see §"WebSocket browser path" below): a WS connection is a long-lived full-duplex channel over which either side can send `call.requested` frames in either direction — the call protocol's native bidirectionality applies unchanged (ADR-012 — stream-agnostic correlation; a WS message stream is another `BiStream`-satisfying transport). WebTransport (`h3`) would restore it via native multi-stream multiplexing, but WebTransport is deferred per ADR-044 — WebSocket is the v1 browser bidirectional path. The HTTP/1.1 + HTTP/2 surface is the projection for clients that only speak HTTP; WebSocket is the surface for browser clients that speak the call protocol in both directions. ### WebSocket browser path (ADR-044) A browser connecting to a hub upgrades an HTTP/1.1 or HTTP/2 request to WebSocket (RFC 6455). The resulting full-duplex WS connection carries call-protocol `EventEnvelope` frames as binary WebSocket messages — one envelope per message. The browser authenticates by bearer token on the upgrade request (the HTTP `Authorization` header), resolved by the hub's `IdentityProvider::resolve_from_token`, same as any HTTP request. The WS connection is then a **bidirectional call-protocol session**: - The browser opens the WS connection to `/alknet/call` (or `/`). - The handler hands the WS message stream to the call protocol's `Dispatcher` — the same dispatch loop the `CallAdapter` uses for `alknet/call` QUIC connections (ADR-012, stream-agnostic correlation). - The browser writes `EventEnvelope` frames as binary WS messages; the handler reads them and dispatches via `OperationRegistry::invoke()`. - Responses (`call.responded`, `call.error`, `call.completed`, `call.aborted`) are written back as binary WS messages. **Bidirectionality:** the WS call-protocol session inherits the call protocol's native bidirectionality — both sides can initiate calls (ADR-043 §2, transferred to WebSocket per ADR-044 §3). The browser calls operations on the hub; the hub can call operations registered on the browser's side, over the same session, using the same `PendingRequestMap` and `EventEnvelope` framing as `alknet/call`. The browser case where the client registers no operations of its own is the common case — the server→client call direction is unused because the browser has nothing to call. That is a use-case scoping, not an architectural limitation. **No SSE translation.** A `Subscription` operation served over WebSocket projects its `call.responded` stream directly as binary WS messages — no SSE `data:` framing. `call.completed` closes the stream; `call.aborted` closes it with an error frame. This is the native streaming projection for the WS path; SSE (ADR-036) is the projection for `h2`/`http/1.1` clients that don't upgrade to WebSocket. **Browsers are not alknet peers.** A browser over WebSocket authenticates by bearer token, gets no `PeerId`, does not enter `PeerCompositeEnv`, and its registered ops (if any) land in a connection-local Layer 2 overlay — the inbound mirror of ADR-034 §2. The rationale (addressability vs. bidirectionality) is stated in ADR-044 §5 and amends ADR-034 §4 by reference. In short: "peer" means an addressable node in the call-protocol peer graph (stable `PeerId`, `PeerRef::Specific`-reachable, identity stable across reconnects), not "any endpoint that exchanges calls during a live session." A browser is the second thing but not the first — it has no stable cryptographic identity of its own (it presents a bearer token the hub issued; nothing to pin), it is ephemeral (close the tab → connection dies → the connection-local overlay dies with it), and it is not addressable from other nodes (another alknet node has no way to reach "the browser currently connected to hub-A"; the hub holds it as a live `CallConnection` handle, not a peer-graph entry). The connection-local overlay is what gives the browser bidirectional-call capability *without* peer-graph membership. **What WebSocket does not provide (deferred to WebTransport, ADR-044):** the ALPN-stream-proxy (ADR-040) — a browser running a WASM parser for SSH/SFTP/git to reach a non-call ALPN — requires WebTransport's multi-stream model and is the speculative use case whose deferral is ADR-044's reversal trigger. WebSocket carries the call protocol from a browser; it does not carry the non-call-ALPN substrate. A browser cannot reach SSH/SFTP/git ALPNs in the v1 release. See ADR-044. ### Auth Inbound HTTP auth is `Authorization: Bearer `, resolved via `IdentityProvider::resolve_from_token()` (the auth.md handler table: `HttpAdapter`, Bearer header, `resolve_from_token`). Bearer-only is the auth mechanism for the default surface; other HTTP auth schemes (Basic, API key in query param) are not implemented and would be added as axum middleware (two-way door). This is recorded in [ADR-036](../../decisions/036-http-to-call-operation-mapping.md) §Auth; the resolution mechanism (`resolve_from_token`) is from [ADR-004](../../decisions/004-auth-as-shared-core.md), and the connection-level observability (`set_identity`) is OQ-11 (resolved). - Bearer-only is the auth mechanism. Basic auth, API keys in query params, and other HTTP auth schemes are not implemented. A deployment that needs a different auth scheme adds it as axum middleware (two-way door), but the default surface is Bearer-only. - The `HttpAdapter` constructor-injects `Arc`, same pattern as `SshAdapter`. - An unauthenticated request to an operation with `AccessControl` restrictions returns `401` (no token) or `403` (token present but insufficient scopes). The call protocol's `FORBIDDEN` protocol code maps to `403`; `NOT_FOUND` (Internal op) maps to `404`. - The HTTP handler stores the resolved identity on the `Connection` for observability (`connection.set_identity(identity)`), same as the call protocol handler. ### Error Mapping Call-protocol `CallError` codes (ADR-023) map to HTTP status codes: | Call `code` | HTTP status | Notes | |-------------|-------------|-------| | `NOT_FOUND` (operation not registered, or Internal op) | `404` | | | `FORBIDDEN` (insufficient scopes, or unauthenticated) | `401` (no token) / `403` (token present) | | | `INVALID_INPUT` (schema mismatch) | `422` | | | `TIMEOUT` | `504` | `retryable: true` | | `INTERNAL` | `500` | | | Operation-level domain code with `http_status` (ADR-023) | the declared `http_status` | `from_openapi`-imported ops carry the original status | | Operation-level domain code without `http_status` | `500` | | The `retryable` field from `CallError` maps to an HTTP `Retry-After` hint for `503`/`429`-class errors. The mapping is a two-way-door default (the exact status for ambiguous codes can be refined additively); the one-way constraint is that protocol-level and operation-level codes are distinct (ADR-023) and `from_openapi`-imported codes are prefixed `HTTP_` to avoid collision with protocol codes. ### `/healthz` (raw route) `GET /healthz` is a raw HTTP route outside the call protocol — no auth, no operation registration, no `OperationContext`. It returns `200 OK` with a plain-text body (e.g., `"ok"`) if the endpoint is healthy. This is the infrastructure endpoint load balancers and orchestrators call; it must work before identity is resolvable. Other operational endpoints (metrics, dashboard) are call-protocol operations if built (`/metrics/list`, `/dashboard/view`), not raw HTTP routes. `healthz` is the one exception. See ADR-036. ### Stealth decoy For paths that are not registered operations (and not `/healthz`, `/openapi.json`, the `to_openapi` gateway endpoints `/search`/`/schema`/ `/call`/`/batch`/`/subscribe`, or the MCP route), the HTTP handler serves a decoy. The decoy is configurable (`DecoyConfig`): - A fake `404 Not Found` (the default — matches the reference implementation's "fake nginx 404"). - A static site (served from a configured directory). - A redirect (to a configured URL). The decoy is the stealth surface: a port scanner or a client that doesn't offer alknet ALPNs connects on `h2`/`http/1.1` and sees the decoy. Real services use `alknet/ssh`, `alknet/call`, etc. The decoy config is a two-way-door default (an operator picks what to serve); the *existence* of the stealth path is fixed by ADR-010. ## Constraints - **The HTTP path IS the operation path on the direct-call surface.** `POST /fs/readFile` → `call.requested` for `fs/readFile`. No second routing table for the direct-call surface. See ADR-036. The `to_openapi` gateway (`/search`, `/schema`, `/call`, `/batch`, `/subscribe`) is a separate fixed-endpoint surface (ADR-042) that coexists with the direct-call surface on the same axum `Router`; it does not replace it. - **`External` operations only.** `Internal` operations return `404` on the HTTP handler. - **Bearer-only auth.** `Authorization: Bearer` → `resolve_from_token`. Other HTTP auth schemes are not implemented. - **No secret material in HTTP responses.** The call protocol carries no secret material (ADR-014); the HTTP handler inherits this constraint. Capabilities are used for outbound calls (`from_openapi`), never serialized into HTTP response bodies. - **`/healthz` is raw.** No auth, no call protocol. The one raw route. - **WebSocket is the browser bidirectional path (ADR-044).** A browser upgrades an HTTP request to WS and speaks the call protocol over binary messages. `h3`/WebTransport is deferred (ADR-044); the ALPN-stream-proxy (ADR-040) is not available in v1. The `h3` ALPN and its feature gate are not implemented in the initial release. ## Design Decisions | Decision | ADR | Summary | |----------|-----|---------| | Direct path mapping (HTTP path = operation path) | [ADR-036](../../decisions/036-http-to-call-operation-mapping.md) | `POST /{service}/{op}` → `call.requested` (direct-call surface) | | `to_openapi` gateway endpoints on the router | [ADR-042](../../decisions/042-openapi-gateway-pattern.md) | `/search`/`/schema`/`/call`/`/batch`/`/subscribe` coexist with the direct-call surface | | SSE projection for subscriptions over h2/http1.1 | [ADR-036](../../decisions/036-http-to-call-operation-mapping.md) | `call.responded` stream → SSE frames | | `/healthz` is a raw route | [ADR-036](../../decisions/036-http-to-call-operation-mapping.md) | No auth, no call protocol | | Stealth decoy | [ADR-010](../../decisions/010-alpn-router-and-endpoint.md) | HTTP handler on standard ALPNs serves decoy | | Bearer auth via `resolve_from_token` | [ADR-004](../../decisions/004-auth-as-shared-core.md) | HTTP handler credential source (settled) | | WebSocket is the browser bidirectional path | [ADR-044](../../decisions/044-defer-webtransport-browsers-use-websocket.md) | Browsers upgrade to WS; `EventEnvelope` over binary messages; `h3`/WebTransport deferred | | Browsers are not alknet peers | [ADR-034](../../decisions/034-outgoing-only-x509-and-three-peer-roles.md) §4 (amended by ADR-044 §5) | Bearer token, no `PeerId`, connection-local overlay (addressability vs. bidirectionality) | | Error mapping (call codes → HTTP status) | [ADR-023](../../decisions/023-operation-error-schemas.md) | Protocol/operation codes distinct; `HTTP_` prefix for imported | ## Open Questions See [open-questions.md](../../open-questions.md) for full details. - **OQ-39** (open): `to_openapi` published-spec versioning — the generated OpenAPI spec is a compatibility contract (ADR-017 Consequences); the versioning strategy needs specifying. - **OQ-40** (resolved): reqwest client config and connection pooling — `ClientWithMiddleware` + middleware stack; the outbound HTTP client used by `from_openapi`/`from_mcp`. ## References - [ADR-036](../../decisions/036-http-to-call-operation-mapping.md) — the HTTP-to-call mapping this server implements - [ADR-044](../../decisions/044-defer-webtransport-browsers-use-websocket.md) — WebSocket is the v1 browser bidirectional path; `h3`/WebTransport deferred. States the "browser is not a peer" rationale (addressability vs. bidirectionality) that ADR-034 §4 closes without arguing. - [overview.md](overview.md) — crate overview, adapter location map - [webtransport.md](webtransport.md) — the deferred `h3` ALPN handler (kept intact for revival) - [http-adapters.md](http-adapters.md) — `from_openapi`/`to_openapi` - [../core/auth.md](../core/auth.md) — `IdentityProvider`, Bearer → `resolve_from_token` - [../core/endpoint.md](../core/endpoint.md) — stealth mode as ALPN dispatch - [../call/operation-registry.md](../call/operation-registry.md) — `OperationRegistry::invoke()`, the dispatch path HTTP requests hit