Files
alknet/docs/architecture/crates/http/http-server.md
glm-5.2 0a78306686 docs(http): add ADR-043 WebTransport bidirectional ALPN substrate; fix spec drift from mid-spec pivot
A consistency review of the alknet-http specs found two classes of
issues: internal contradictions from the mid-spec pivot (the to_openapi
gateway pattern landed in prose but not in cross-references), and a
systematic client→server assumption that only holds for the OpenAPI/MCP
case leaking into the WebTransport architecture.

Class 1 (internal contradictions):
- C1: to_openapi was half-refactored — body described the ADR-042
  gateway pattern but the decisions table and ADR-036 still said
  'paths mirror /{service}/{op}'. ADR-036's to_openapi clause is now
  amended as superseded by ADR-042; the stale decisions row and README
  Principle 2 are fixed.
- C2: the axum Router route list didn't include the 5 gateway endpoints
  (/search, /schema, /call, /batch, /subscribe). Added them; clarified
  /openapi.json as the gateway description doc; added gateway paths to
  the decoy exclusion list.
- C3: ADR-034 §5 still talked about the 'h3/WebTransport deferral
  bucket' that ADR-038 eliminated. Amended §5/Consequences/References
  to drop the deferral framing (the auth-model decision stands; only
  the 'when' wording was stale).

Class 2 (one-way direction assumption):
- C4/C5/C6: the WebTransport specs framed the session as browser→hub
  one-way, when the call protocol is bidirectional and WebTransport is
  a general ALPN transport substrate. New ADR-043 reframes WebTransport
  as a bidirectional ALPN transport substrate (call protocol is the
  first/canonical target; needs no WASM parser), names the call
  protocol's bidirectionality over WebTransport sessions, and states
  the inbound no-PeerId connection-local overlay as the mirror of
  ADR-034 §2. webtransport.md is updated to reflect this framing;
  ADR-040 is repositioned (not superseded) as the substrate's non-call-
  ALPN mechanism.
- C7: the HTTP/1.1+HTTP/2 surface's one-directionality is now named as
  a lossy consequence of HTTP request/response; WebTransport is named
  as the surface that restores the bidirectional call model.
- C8: overview.md acknowledges the from/to direction model is
  OpenAPI/MCP-specific, not a call-protocol property.

A review subagent pass on ADR-043 + webtransport.md found no critical
issues; warnings W1-W3 (residual browser-as-subject framing, ADR-009
rationale in spec, opening abstract tone) and suggestions S2/S4/S5
were addressed.
2026-06-29 10:43:18 +00:00

17 KiB

status, last_updated
status last_updated
draft 2026-06-29

HTTP Server

The HttpAdapter — the ProtocolHandler for h2 and http/1.1 (and h3, covered in 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, and stealth decoy.

What

The HttpAdapter is constructed by the assembly layer with an Arc<dyn IdentityProvider> (constructor injection, same pattern as SshAdapter — see auth.md) and an Arc<OperationRegistry> (for dispatching HTTP requests to call-protocol operations). It implements ProtocolHandler for the standard HTTP ALPNs.

pub struct HttpAdapter {
    identity_provider: Arc<dyn IdentityProvider>,
    registry: Arc<OperationRegistry>,
    /// 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, h3). 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; for h3, it's the WebTransport/HTTP/3 path (see 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<OperationRegistry> and Arc<dyn IdentityProvider> embedded in its state; cloning the Router per connection clones the Arcs (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).

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/readFilefs/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 WebTransport (h3), the subscription projects directly onto a WebTransport bidirectional stream — no SSE framing (see 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 §"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. WebTransport (h3) restores the bidirectional call model: a WebTransport session is a long-lived connection over which either side can open bidirectional streams and send call.requested events in either direction — the call protocol's native bidirectionality applies unchanged. See webtransport.md and ADR-043. The HTTP/1.1 + HTTP/2 surface is the projection for clients that only speak HTTP; WebTransport is the surface for clients that can speak the call protocol in both directions.

Auth

Inbound HTTP auth is Authorization: Bearer <token>, 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 §Auth; the resolution mechanism (resolve_from_token) is from ADR-004, 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<dyn IdentityProvider>, 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_<status> 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/readFilecall.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: Bearerresolve_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.
  • The h3 ALPN is a first-class transport. The HttpAdapter registers for h3 when the h3 feature is enabled (ADR-038). The h3 handler is covered in webtransport.md; this document covers the h2/http/1.1 path.

Design Decisions

Decision ADR Summary
Direct path mapping (HTTP path = operation path) ADR-036 POST /{service}/{op}call.requested (direct-call surface)
to_openapi gateway endpoints on the router ADR-042 /search//schema//call//batch//subscribe coexist with the direct-call surface
SSE projection for subscriptions over h2/http1.1 ADR-036 call.responded stream → SSE frames
/healthz is a raw route ADR-036 No auth, no call protocol
Stealth decoy ADR-010 HTTP handler on standard ALPNs serves decoy
Bearer auth via resolve_from_token ADR-004 HTTP handler credential source (settled)
h3 is first-class (not deferred) ADR-038 The h3 ALPN handler lives in this crate
Error mapping (call codes → HTTP status) ADR-023 Protocol/operation codes distinct; HTTP_<status> prefix for imported

Open Questions

See 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 (open): reqwest client config and connection pooling — two-way-door config shape for the outbound HTTP client used by from_openapi/from_mcp.

References