docs(http): decompose alknet-http spec into 19 implementation tasks

Break the alknet-http architecture spec into atomic, dependency-ordered
tasks in tasks/http/, following the taskgraph frontmatter conventions
used by the call/core/vault crates.

Tasks span 7 phases across 5 module subdirectories (server/, gateway/,
client/, adapters/, websocket/):
- Phase 0: crate-init (foundation)
- Phase 1: gateway-dispatch-spine, error-mapping, shared-http-client
  (shared infrastructure)
- Phase 2: http-adapter, bearer-auth-middleware, gateway-endpoints,
  healthz-decoy (HTTP server surface)
- Phase 3: to-openapi (OpenAPI gateway projection)
- Phase 4: from-openapi (OpenAPI adapter, reqwest forwarding)
- Phase 5: dispatcher-transport-abstraction, upgrade-handler,
  connection-overlay (WebSocket browser bidirectional path)
- Phase 6: from-mcp, to-mcp (MCP adapters, feature-gated)
- Phase 7: review-http, review-websocket, review-mcp, review-http-final
  (quality checkpoints)

The gateway-dispatch-spine task implements the thin shared core
recommended by the gateway-factoring research (concrete struct, not a
trait). The dispatcher-transport-abstraction task is a cross-crate
change to alknet-call (exposes EventEnvelope-level dispatch API for
non-QUIC transports) — the highest-risk task. WebTransport/h3 is
deferred per ADR-044 and has no tasks; from_wss is out of scope.

Validated: 19 tasks, no cycles, 8 parallel generations, critical path
length 8 (through the WebSocket strand).
This commit is contained in:
2026-07-01 07:11:17 +00:00
parent e0c6f61e6a
commit e855c8c7eb
19 changed files with 3493 additions and 0 deletions

View File

@@ -0,0 +1,179 @@
---
id: http/server/bearer-auth-middleware
name: Implement shared Bearer auth middleware (resolve_from_token, stash Identity in request extensions)
status: pending
depends_on: [http/server/http-adapter]
scope: narrow
risk: medium
impact: component
level: implementation
---
## Description
Implement the shared Bearer auth axum middleware in
`src/server/auth.rs`. This is the auth layer shared by the HTTP gateway
endpoints AND the `to_mcp` rmcp service (research §4.4: "the auth
middleware is shareable now"). One axum layer resolves the bearer token
and stashes `Option<Identity>` in request extensions; the `to_openapi`
route handlers read it from axum state/extractors, and the `to_mcp`
`call_tool` handler reads it from rmcp's `RequestContext.extensions`
(rmcp injects `http::request::Parts` into extensions — research §4.4,
`tower.rs:487-521, 1086-1097`).
### The middleware (http-server.md §"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).
```rust
/// Axum middleware that resolves the `Authorization: Bearer` header via
/// `IdentityProvider::resolve_from_token()` and stashes the resolved
/// `Option<Identity>` in request extensions. Shared by the HTTP gateway
/// endpoints and the to_mcp rmcp service (research §4.4).
pub async fn bearer_auth_middleware(
State(identity_provider): State<Arc<dyn IdentityProvider>>,
mut request: Request,
next: Next,
) -> Response {
let identity = extract_bearer_identity(&request, &identity_provider);
request.extensions_mut().insert(identity);
next.run(request).await
}
/// Extract the `Authorization: Bearer <token>` header and resolve it to
/// an `Option<Identity>`. Returns `None` if no token is present (the
/// request proceeds unauthenticated; the route handler / AccessControl
/// decides whether to reject). Returns `None` if the token is present
/// but resolution fails (treat as unauthenticated, not as an error —
/// matches the CallAdapter's per-request identity resolution behavior).
pub fn extract_bearer_identity(
request: &Request,
identity_provider: &dyn IdentityProvider,
) -> Option<Identity> {
let header = request.headers().get(AUTHORIZATION)?;
let token_str = header.to_str().ok()?.strip_prefix("Bearer ")?;
let token = AuthToken { raw: token_str.as_bytes().to_vec() };
identity_provider.resolve_from_token(&token)
}
```
### Auth resolution behavior
- 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
`error-mapping` task owns the status mapping; this task resolves the
identity and stashes it.)
- The HTTP handler stores the resolved identity on the `Connection` for
observability (`connection.set_identity(identity)`), same as the call
protocol handler (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 `Identity` extractor
Provide an axum extractor so route handlers can declare `identity:
Option<Identity>` as a parameter and get the resolved identity from
extensions:
```rust
/// Axum extractor: the resolved bearer identity (or None if
/// unauthenticated). Read from request extensions (stashed by
/// `bearer_auth_middleware`).
#[derive(Clone, Debug)]
pub struct ResolvedIdentity(pub Option<Identity>);
#[async_trait]
impl FromRequestParts<AppState> for ResolvedIdentity {
type Rejection = Infallible;
async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Self, Self::Rejection> {
Ok(ResolvedIdentity(parts.extensions.get::<Option<Identity>>().cloned().flatten_or(None)))
}
}
```
### Shared with `to_mcp` (research §4.4)
The `to_mcp` rmcp service is nested into the axum router via
`Router::nest_service("/mcp", mcp_service)` (the `to-mcp` task). The
Bearer auth middleware is applied as an axum layer *around* the nested
service (the rmcp `simple_auth_streamhttp.rs` example shows the pattern:
`middleware::from_fn_with_state` around `Router::nest_service`). The
`to_mcp` `call_tool` handler reads the `Identity` from
`RequestContext<RoleServer>.extensions` (rmcp injects
`http::request::Parts` into extensions — `tower.rs:487-521, 1086-1097`).
A spike should confirm this extension-survives-the-rmcp-framing path
works end-to-end — it is the load-bearing assumption for sharing the auth
middleware (research §6 open question #2). The `Identity` stashed by the
axum middleware into `Parts.extensions` should be retrievable via
`ctx.extensions.get::<Identity>()` inside `call_tool`.
### What this task does NOT do
- **No AccessControl enforcement.** The middleware resolves identity;
the route handlers / `GatewayDispatch::invoke()` enforce
`AccessControl::check(identity)`. This task stashes the identity; it
does not reject requests (except for malformed `Authorization` headers,
which are treated as no-token, not as errors).
- **No error response mapping.** The `401`/`403`/`404` status mapping is
the `error-mapping` task. This task resolves identity; the route
handler produces the `CallError`, and the error-mapping task maps it.
- **No `to_mcp` service.** The rmcp service is the `to-mcp` task. This
task provides the middleware that wraps it.
## Acceptance Criteria
- [ ] `bearer_auth_middleware` axum middleware in `src/server/auth.rs`
- [ ] Extracts `Authorization: Bearer <token>` header
- [ ] Resolves via `identity_provider.resolve_from_token(&AuthToken { raw })`
- [ ] Stashes `Option<Identity>` in request extensions
- [ ] No token present → `None` identity (request proceeds, route handler decides)
- [ ] Malformed `Authorization` header → `None` identity (not an error)
- [ ] Token present but resolution fails → `None` identity (treat as unauthenticated)
- [ ] `ResolvedIdentity` axum extractor reads from extensions
- [ ] Middleware is `pub` and re-exported from `lib.rs`
- [ ] Middleware applicable to both HTTP routes and nested rmcp service (research §4.4)
- [ ] `connection.set_identity(identity)` called for observability (OQ-11)
- [ ] No `std::env::var` reads (no-env-vars invariant)
- [ ] Unit test: request with valid Bearer token → `Some(identity)` in extensions
- [ ] Unit test: request with no `Authorization` header → `None` in extensions
- [ ] Unit test: request with malformed `Authorization``None` in extensions
- [ ] Unit test: request with `Basic` auth → `None` (Bearer-only, not an error)
- [ ] Unit test: `ResolvedIdentity` extractor retrieves stashed identity
- [ ] `cargo test -p alknet-http` succeeds
- [ ] `cargo clippy -p alknet-http --all-targets` succeeds with no warnings
## References
- docs/architecture/crates/http/http-server.md — Auth (§"Auth")
- docs/research/alknet-http-gateway-factoring/findings.md — §4.4 (auth-extraction convergence, shareable now)
- docs/architecture/crates/core/auth.md — IdentityProvider, resolve_from_token
- docs/architecture/decisions/004-auth-as-shared-core.md — ADR-004 (Bearer → resolve_from_token)
- /workspace/rust-sdk/examples/servers/src/simple_auth_streamhttp.rs — rmcp axum middleware pattern
## Notes
> The auth middleware is the second small shared piece (alongside the
> dispatch spine). It is shareable between the HTTP gateway routes and
> the to_mcp rmcp service because both use axum middleware — the rmcp
> service is nested via Router::nest_service, and the middleware is
> applied around it. The load-bearing assumption is that the Identity
> stashed in Parts.extensions survives the rmcp framing and is
> retrievable via ctx.extensions.get::<Identity>() inside call_tool
> (research §6 open question #2 — confirm with a spike). This task
> resolves identity and stashes it; it does not enforce AccessControl
> (that's the route handler / GatewayDispatch's job) or map errors
> (that's the error-mapping task).
## Summary
> To be filled on completion

View File

@@ -0,0 +1,194 @@
---
id: http/server/gateway-endpoints
name: Implement 5 gateway endpoints (search/schema/call/batch/subscribe) — axum route handlers
status: pending
depends_on: [http/server/http-adapter, http/gateway/gateway-dispatch-spine, http/gateway/error-mapping, http/server/bearer-auth-middleware]
scope: broad
risk: medium
impact: component
level: implementation
---
## Description
Implement the 5 fixed gateway endpoints in `src/server/gateway_routes.rs`.
These are the sole invoke path over HTTP (ADR-042, ADR-047): an HTTP
client invokes an operation via `POST /call` with
`{ "operation": "/fs/readFile", "input": {...} }`, discovers what it can
call via the `AccessControl`-filtered `GET /search`, and learns an
operation's shape via `GET /schema`. There is no per-operation
`POST /{service}/{op}` direct-call surface — the gateway is the invoke
path (ADR-047 supersedes ADR-036's direct-call surface).
### The 5 endpoints (http-server.md §"HTTP-to-call dispatch", http-adapters.md §"The gateway endpoint set")
| Endpoint | Call protocol | HTTP method | Purpose |
|----------|--------------|-------------|---------|
| `/search` | `services/list` | `GET` | List/search operations (AccessControl-filtered). Names + descriptions. |
| `/schema` | `services/schema` | `GET` | Get an operation's full `OperationSpec`. |
| `/call` | `call.requested` (Query/Mutation) | `POST` | Invoke an operation. Flat JSON body `{ operation, input }`. |
| `/batch` | multiple `call.requested` | `POST` | Invoke multiple operations. Array of `{ operation, input }`. |
| `/subscribe` | `call.requested` (Subscription) | `POST` (SSE) | Invoke a streaming operation. Body `{ operation, input }`; response `text/event-stream`. |
### `POST /call` dispatch (http-server.md §"HTTP-to-call dispatch")
1. The axum route handler reads the JSON body
`{ "operation": "/fs/readFile", "input": {...} }`.
2. Resolves the caller's identity from the `Authorization: Bearer` header
(via the shared `bearer_auth_middleware` — stashed in extensions as
`ResolvedIdentity`).
3. Calls `GatewayDispatch::invoke(identity, operation, input)` — the
shared dispatch spine (the `gateway-dispatch-spine` task). This builds
the root `OperationContext` (`internal: false`, `forwarded_for: None`)
and dispatches through `OperationRegistry::invoke()`.
4. The response (`ResponseEnvelope`) is serialized as the HTTP response
body (JSON). Errors map to HTTP status codes via the `error-mapping`
task (`call_error_to_http_response`).
`Internal` operations (ADR-015) return `404` (`NOT_FOUND`) — the gateway
dispatches only `External` operations, and the caller discovers which
`External` operations it can call via the `AccessControl`-filtered
`/search` endpoint. This is the per-caller API surface property: an HTTP
client cannot stub its toe on a path for an operation it can't call,
because there is no per-operation path — `/search` tells it what it can
call, `/call` invokes it, and the `AccessControl` check runs on `/call`
regardless.
### `GET /search` (AccessControl-filtered discovery)
Dispatches `services/list` through `GatewayDispatch::invoke()` with the
resolved caller identity. The `services/list` handler (already in
`OperationRegistry`) filters by `AccessControl::check(identity)` — the
client sees only the operations it is authorized to call. Returns
operation names + descriptions (not full schemas). Query parameters for
filtering/searching are a two-way-door extension (the v1 shape is "list
all I can call"; search/filter sugar is additive).
### `GET /schema`
Dispatches `services/schema` through `GatewayDispatch::invoke()` with
the resolved caller identity. Returns the operation's full
`OperationSpec` (input/output JSON Schemas, error schemas). The
`AccessControl` check runs (an unauthorized caller gets `FORBIDDEN`, not
the schema).
### `POST /batch`
Follows the same dispatch path as `/call` with an array of
`{ operation, input }` pairs (OQ-14). `batch` is a loop over
`GatewayDispatch::invoke()` in the gateway (research §6 open question
#3 — confirm `batch` is genuinely just a loop, no shared batch-specific
state, no transactional semantics). Returns an array of results (or
errors), one per entry, in order.
### `POST /subscribe` (SSE streaming projection)
A `Subscription` operation invoked via the gateway's `POST /subscribe`
endpoint projects its `call.responded` stream as Server-Sent Events. The
request body is `{ operation, input }` (the same flat JSON shape as
`/call`); the response is `text/event-stream` (negotiated via
`Accept: text/event-stream` on the `POST`). 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.md), 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.
### One-directional projection (http-server.md §"One-directional projection")
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. The HTTP projection carries only the client→server call
direction; the server→client call direction has no HTTP expression.
`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. WebSocket restores the bidirectional call model for
browsers (the `websocket/` tasks).
### Constraints
- **The gateway is the sole invoke path over HTTP (ADR-042, ADR-047).**
No per-operation `POST /{service}/{op}` direct-call surface.
- **`External` operations only.** `Internal` operations return `404` on
the gateway's `/call`, matching the call protocol's `NOT_FOUND`.
- **Bearer-only auth.** Via the shared `bearer_auth_middleware`.
- **No secret material in HTTP responses.** Capabilities are used for
outbound calls (`from_openapi`), never serialized into HTTP response
bodies (ADR-014).
## Acceptance Criteria
- [ ] `POST /call` route handler reads `{ operation, input }` JSON body
- [ ] `/call` resolves identity via `ResolvedIdentity` extractor (shared middleware)
- [ ] `/call` dispatches via `GatewayDispatch::invoke(identity, operation, input)`
- [ ] `/call` response is `ResponseEnvelope` serialized as JSON
- [ ] `/call` errors mapped via `call_error_to_http_response` (error-mapping task)
- [ ] `Internal` op on `/call``404 NOT_FOUND`
- [ ] `External` op with `AccessControl` restrictions + unauthorized → `403 FORBIDDEN`
- [ ] `External` op with `AccessControl` restrictions + no identity → `401`
- [ ] `GET /search` dispatches `services/list` via `GatewayDispatch::invoke`
- [ ] `/search` results are `AccessControl::check(identity)`-filtered
- [ ] `/search` returns operation names + descriptions (not full schemas)
- [ ] `GET /schema` dispatches `services/schema` via `GatewayDispatch::invoke`
- [ ] `/schema` returns the operation's full `OperationSpec`
- [ ] `/schema` for unauthorized op → `403 FORBIDDEN`
- [ ] `POST /batch` dispatches an array of `{ operation, input }` via loop over `invoke`
- [ ] `/batch` returns an array of results (or errors), one per entry, in order
- [ ] `POST /subscribe` sets `Content-Type: text/event-stream`
- [ ] `/subscribe` writes `call.responded` events as SSE `data:` frames
- [ ] `/subscribe` closes stream on `call.completed`
- [ ] `/subscribe` writes SSE error event on `call.aborted`
- [ ] `/subscribe` sends `call.aborted` on HTTP client disconnect (ADR-016 cascade)
- [ ] No per-operation `POST /{service}/{op}` direct-call surface (ADR-047)
- [ ] No secret material in HTTP response bodies (ADR-014)
- [ ] Integration test: `/call` round-trip (External op → 200 + JSON body)
- [ ] Integration test: `/call` Internal op → 404
- [ ] Integration test: `/call` unauthorized → 403
- [ ] Integration test: `/call` unauthenticated + restricted op → 401
- [ ] Integration test: `/search` returns only AccessControl-allowed ops
- [ ] Integration test: `/schema` returns full spec for authorized op
- [ ] Integration test: `/batch` returns array of results in order
- [ ] Integration test: `/subscribe` streams SSE events until completed
- [ ] Integration test: `/subscribe` client disconnect → abort cascade
- [ ] `cargo test -p alknet-http` succeeds
- [ ] `cargo clippy -p alknet-http --all-targets` succeeds with no warnings
## References
- docs/architecture/crates/http/http-server.md — HTTP-to-call dispatch, SSE projection, one-directional projection
- docs/architecture/crates/http/http-adapters.md — The gateway endpoint set, per-caller API surface
- docs/architecture/decisions/042-openapi-gateway-pattern.md — ADR-042 (5 fixed gateway endpoints)
- docs/architecture/decisions/047-remove-direct-call-http-surface.md — ADR-047 (gateway is sole invoke path)
- docs/architecture/decisions/015-privilege-model-and-authority-context.md — ADR-015 (Internal → 404)
- docs/architecture/decisions/016-abort-cascade-for-nested-calls.md — ADR-016 (disconnect → abort cascade)
- docs/architecture/decisions/014-secret-material-flow-and-capability-injection.md — ADR-014 (no secrets in responses)
## Notes
> The 5 gateway endpoints are the sole HTTP invoke path (ADR-047). The
> /call handler delegates to GatewayDispatch::invoke (the shared spine);
> the error mapping is the error-mapping task; the auth is the shared
> bearer-auth-middleware. /subscribe is the SSE streaming projection —
> the one to_openapi-specific piece that does not go through the shared
> spine's request/response invoke (research §6 open question #5 —
> /subscribe is to_openapi-owned, not in the shared core). /batch is a
> loop over invoke (research §6 open question #3 — confirm no
> batch-specific shared state). The one-directional projection is a
> structural property of HTTP; WebSocket restores bidirectionality for
> browsers.
## Summary
> To be filled on completion

View File

@@ -0,0 +1,146 @@
---
id: http/server/healthz-decoy
name: Implement /healthz raw route and stealth decoy fallback (DecoyConfig)
status: pending
depends_on: [http/server/http-adapter]
scope: narrow
risk: low
impact: component
level: implementation
---
## Description
Implement the `/healthz` raw route and the stealth decoy fallback in
`src/server/healthz.rs` and `src/server/decoy.rs`. These are the two
non-gateway HTTP surfaces on the `HttpAdapter` router: the one raw
operational endpoint (`/healthz`) and the stealth fallback for unknown
paths (the decoy).
### `GET /healthz` (raw route, http-server.md §"/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.
```rust
/// GET /healthz — raw health check. No auth, no call protocol.
/// Returns 200 OK with plain-text body "ok" if the endpoint is healthy.
async fn healthz() -> impl IntoResponse {
(StatusCode::OK, [("content-type", "text/plain"), "ok"])
}
```
Other operational endpoints (metrics, dashboard) are call-protocol
operations if built (`/metrics/list`, `/dashboard/view`), not raw HTTP
routes. `healthz` is the one exception (ADR-036).
### Stealth decoy (http-server.md §"Stealth decoy")
For paths that are not the gateway endpoints (`/search`, `/schema`,
`/call`, `/batch`, `/subscribe`), `/healthz`, `/openapi.json`, the MCP
route, the WS upgrade route, or a custom route per ADR-046, the HTTP
handler serves a decoy. The decoy is configurable (`DecoyConfig`):
- `NotFound` — A fake `404 Not Found` (the default — matches the
reference implementation's "fake nginx 404").
- `StaticSite { root }` — Serve a static site from a configured
directory. For deployments that want a real decoy website.
- `Redirect { to }` — 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.
Custom routes (ADR-046) take precedence over the decoy — a path matched
by a custom route is served by it, not the decoy; the decoy is the
fallback for paths matched by neither the default surface nor a custom
route.
### The fallback handler
```rust
/// Fallback handler for unknown paths (stealth decoy). Serves the
/// configured DecoyConfig: fake 404 (default), static site, or redirect.
async fn decoy_fallback(
State(decoy): State<DecoyConfig>,
request: Request,
) -> Response {
match decoy {
DecoyConfig::NotFound => fake_nginx_404(),
DecoyConfig::StaticSite { root } => serve_static(root, request).await,
DecoyConfig::Redirect { to } => redirect(to),
}
}
```
The `NotFound` variant should match the reference implementation's
"fake nginx 404" — a realistic 404 page that looks like a generic web
server, not an alknet-specific error. The exact body is a two-way-door
implementation detail; the one-way constraint is that it does not leak
alknet's presence (no alknet headers, no alknet error format).
### Wiring into the router
The `healthz` route and the `decoy_fallback` are wired into the axum
`Router` by the `http-adapter` task. This task implements the handlers;
the `http-adapter` task's router construction calls them:
```rust
// In the http-adapter task's router construction:
let router = Router::new()
.route("/healthz", get(healthz)) // this task
.fallback(decoy_fallback) // this task
// ... gateway endpoints, /openapi.json, MCP, WS upgrade ...
```
## Acceptance Criteria
- [ ] `GET /healthz` handler returns `200 OK` with plain-text body `"ok"`
- [ ] `/healthz` requires no auth (no Bearer token check)
- [ ] `/healthz` does not construct an `OperationContext` (raw route)
- [ ] `DecoyConfig::NotFound` serves a fake 404 (no alknet-specific headers/format)
- [ ] `DecoyConfig::StaticSite { root }` serves static files from `root`
- [ ] `DecoyConfig::Redirect { to }` returns an HTTP redirect to `to`
- [ ] `DecoyConfig::default()` returns `NotFound`
- [ ] Decoy fallback serves for paths not matched by any other route
- [ ] Custom routes (ADR-046) take precedence over decoy (decoy is fallback only)
- [ ] Gateway endpoints, `/healthz`, `/openapi.json`, MCP route, WS upgrade take precedence over decoy
- [ ] Decoy does not leak alknet presence (no alknet headers, no alknet error format)
- [ ] Unit test: `/healthz` returns 200 + "ok"
- [ ] Unit test: unknown path with `NotFound` decoy → 404
- [ ] Unit test: unknown path with `StaticSite` decoy → static file
- [ ] Unit test: unknown path with `Redirect` decoy → redirect
- [ ] Unit test: `/healthz` works with no `Authorization` header
- [ ] Integration test: custom route matched → custom handler (not decoy)
- [ ] Integration test: unknown path not matched by custom route → decoy
- [ ] `cargo test -p alknet-http` succeeds
- [ ] `cargo clippy -p alknet-http --all-targets` succeeds with no warnings
## References
- docs/architecture/crates/http/http-server.md — /healthz (§"/healthz (raw route)"), Stealth decoy (§"Stealth decoy")
- docs/architecture/decisions/010-alpn-router-and-endpoint.md — ADR-010 (stealth, decoy existence)
- docs/architecture/decisions/036-http-to-call-operation-mapping.md — ADR-036 (/healthz is the one raw route)
- docs/architecture/decisions/046-assembly-layer-custom-http-routes.md — ADR-046 (custom routes take precedence over decoy)
## Notes
> /healthz is the one raw route — no auth, no call protocol, no
> OperationContext. It must work before identity is resolvable (load
> balancers call it). The decoy is the stealth surface: a port scanner
> sees the decoy, not alknet. The decoy config is a two-way-door
> (operator picks NotFound/StaticSite/Redirect); the existence of the
> stealth path is fixed by ADR-010. The NotFound variant should look
> like a generic web server's 404, not an alknet error — no alknet
> headers, no alknet format. Custom routes take precedence over the
> decoy; the decoy is the fallback for paths matched by neither the
> default surface nor a custom route.
## Summary
> To be filled on completion

View File

@@ -0,0 +1,217 @@
---
id: http/server/http-adapter
name: Implement HttpAdapter (ProtocolHandler for h2/http1.1) — axum over QUIC stream, ALPN branching, custom routes
status: pending
depends_on: [http/crate-init, http/gateway/gateway-dispatch-spine]
scope: broad
risk: high
impact: component
level: implementation
---
## Description
Implement `HttpAdapter` in `src/server/adapter.rs`. This is the
`ProtocolHandler` implementation for the standard HTTP ALPNs (`h2`,
`http/1.1`) — the highest-risk task in the http crate. It ties together
the axum-over-QUIC-stream integration, ALPN branching, the router
construction (gateway endpoints + `/healthz` + `/openapi.json` + MCP +
custom routes + decoy), and the `extra_routes: Option<Router>` extension
point (ADR-046).
### The struct (http-server.md §"What")
```rust
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,
/// Deployment-specific routes added by the assembly layer (ADR-046).
/// None = the default surface only. Custom routes are raw HTTP, not
/// call-protocol operations; they coexist with the default surface and
/// are not described by to_openapi.
extra_routes: Option<Router>,
}
pub enum DecoyConfig {
/// Serve a fake `404 Not Found` (the default — "fake nginx 404").
NotFound,
/// Serve a static site from a configured directory.
StaticSite { root: PathBuf },
/// Redirect to a configured URL.
Redirect { to: String },
}
```
### ProtocolHandler impl
```rust
#[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
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 WS handler is
the `websocket/upgrade-handler` task; this task hosts the route).
### Running axum over a QUIC stream (http-server.md §"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 `Arc`s (cheap,
shared state), so every request handler has access to the registry and
identity provider through the router's state.
### The router surface (http-server.md §"Architecture")
The axum `Router` is the single routing surface for HTTP requests. It
contains:
- **The `to_openapi` gateway endpoints** (`/search`, `/schema`, `/call`,
`/batch`, `/subscribe` — ADR-042). These 5 fixed endpoints are the sole
invoke path over HTTP. (Route handlers are the `gateway-endpoints`
task; this task wires the router.)
- `GET /healthz` (raw route, no auth, no call protocol). (The
`healthz-decoy` task; this task wires the route.)
- `GET /openapi.json` (serves the `to_openapi` projection). (The
`to-openapi` task; this task wires the route.)
- The stealth decoy fallback (unknown paths). (The `healthz-decoy`
task; this task wires the fallback.)
- (Feature-gated) `POST /mcp` (the `to_mcp` streamable HTTP service). (The
`to-mcp` task; this task wires the route behind the `mcp` feature gate.)
- **Deployment-specific custom routes** (ADR-046). The assembly layer
may inject an `axum::Router` of extra routes at `HttpAdapter`
construction. (This task implements the `extra_routes` merge.)
### Custom routes (ADR-046)
Custom routes:
- Are **raw HTTP**, not call-protocol operations — not registered in the
`OperationRegistry`, not discoverable via `/search`, not in the
`to_openapi` gateway doc.
- **May** dispatch into the registry via
`OperationRegistry::invoke()` with a proper `OperationContext`
(caller identity from the resolved bearer token) — the OAI proxy
pattern. Or they may be pure HTTP (a webhook receiver, a static asset
server) that never touches the registry.
- Run under the **default Bearer-auth middleware**; a route that wants
different auth applies its own axum middleware (the deployment owns
its custom routes' middleware stack).
- **Do not collide** with the reserved default-surface paths
(`/search`, `/schema`, `/call`, `/batch`, `/subscribe`, `/healthz`,
`/openapi.json`, the MCP route) — the default surface wins on
collision; custom routes namespace away naturally (`/v1/...`).
- Are **not versioned** by `to_openapi` (ADR-045 versions the gateway
contract, not custom routes).
- Are **immutable after construction** (matches OQ-04 / ADR-010's
static-registration constraint; the `HttpAdapter` router is built once
at startup).
The extension point is additive: a deployment that passes `None` gets
exactly the default surface. The mechanism (the constructor parameter)
is the one-way door — once downstream deployments build against it, it's
a contract (ADR-046). The specific routes a deployment adds are a
two-way door (add/remove freely).
### ALPN branching
The `HttpAdapter` registers for `http/1.1` and `h2`. The endpoint's
`HandlerRegistry` maps each ALPN to the same `HttpAdapter` instance; the
handler branches on `connection.remote_alpn()` to pick the right
framing. The `h3` ALPN is not registered in v1 (deferred per ADR-044).
### What this task does NOT do
- **No gateway route handlers.** The 5 gateway endpoints' handler logic
is the `gateway-endpoints` task. This task wires the routes into the
router and provides the router state.
- **No `/healthz` or decoy logic.** The `healthz-decoy` task implements
the healthz handler and the decoy fallback. This task wires the
routes.
- **No `/openapi.json` generation.** The `to-openapi` task implements
the OpenAPI doc generation. This task wires the route.
- **No MCP route.** The `to-mcp` task implements the rmcp service. This
task wires the route behind the `mcp` feature gate.
- **No WebSocket upgrade handler.** The `websocket/upgrade-handler` task
implements the WS upgrade. This task hosts the WS upgrade route on the
router (the WS task depends on this task's router).
## Acceptance Criteria
- [ ] `HttpAdapter` struct with `identity_provider`, `registry`, `decoy`, `extra_routes`
- [ ] `DecoyConfig` enum with `NotFound`, `StaticSite { root }`, `Redirect { to }`
- [ ] `HttpAdapter::new(identity_provider, registry)` constructor
- [ ] `HttpAdapter::with_decoy(self, decoy)` builder
- [ ] `HttpAdapter::with_extra_routes(self, routes: Router)` builder (ADR-046)
- [ ] `ProtocolHandler::alpn()` returns the configured ALPN (`http/1.1` or `h2`)
- [ ] `handle()` branches on `connection.remote_alpn()` for HTTP framing
- [ ] `handle()` accepts a QUIC bidirectional stream via `connection.accept_bi()`
- [ ] `handle()` wraps the stream as a hyper `TokioIo`-compatible duplex
- [ ] `handle()` drives hyper's `http1::Builder` or `http2::Builder::serve_connection`
- [ ] axum `Router` built once at construction, cloned per connection
- [ ] Router state holds `Arc<OperationRegistry>` + `Arc<dyn IdentityProvider>`
- [ ] Custom routes (`extra_routes`) merged via `Router::merge` (ADR-046)
- [ ] Default surface reserved paths take precedence on collision with custom routes
- [ ] `h3` ALPN is not registered (deferred per ADR-044)
- [ ] `handle()` returns when the HTTP connection closes
- [ ] Unit test: `alpn()` returns `http/1.1` or `h2` per config
- [ ] Unit test: `DecoyConfig::default()` is `NotFound`
- [ ] Unit test: `with_extra_routes` merges routes without collision on reserved paths
- [ ] Integration test: `handle()` serves an HTTP request over a mock QUIC stream
- [ ] Integration test: custom route (`/v1/foo`) coexists with default surface
- [ ] Integration test: reserved path (`/healthz`) wins over a custom route collision
- [ ] `cargo test -p alknet-http` succeeds
- [ ] `cargo clippy -p alknet-http --all-targets` succeeds with no warnings
## References
- docs/architecture/crates/http/http-server.md — HttpAdapter, axum over QUIC, custom routes
- docs/architecture/decisions/010-alpn-router-and-endpoint.md — ADR-010 (stealth, ALPN dispatch)
- docs/architecture/decisions/046-assembly-layer-custom-http-routes.md — ADR-046 (extra_routes)
- docs/architecture/decisions/044-defer-webtransport-browsers-use-websocket.md — ADR-044 (h3 deferred)
- docs/architecture/decisions/039-http-server-and-client-host-colocated.md — ADR-039 (one crate)
## Notes
> This is the highest-risk task in the http crate — the axum-over-QUIC
> integration is the merge point. The router is built once at
> construction with the registry and identity provider in its state;
> cloning per connection is cheap (Arc clone). The extra_routes
> extension point (ADR-046) is additive: None = default surface only;
> Some(routes) = default + custom, with default winning on collision.
> The h3 ALPN is not registered (deferred per ADR-044). This task wires
> the router and provides the QUIC-to-hyper bridge; the gateway
> endpoints, healthz, decoy, openapi.json, MCP, and WS upgrade route are
> wired by their respective tasks (which depend on this task's router).
## Summary
> To be filled on completion