docs(http): draft alknet-http architecture specs and ADRs 036-039
First speccing pass for alknet-http (HTTP interface crate: h2/http1.1/h3 server + from_openapi/to_openapi/from_mcp/to_mcp adapters). Specs (crates/http/): - README.md, overview.md — crate index, two-roles-in-one-crate framing, adapter location map, feature gates (h3, mcp), no-env-vars invariant - http-server.md — HttpAdapter for h2/http1.1, axum over QUIC stream, Bearer auth, SSE projection for subscriptions, /healthz, stealth decoy - http-adapters.md — from_openapi (reqwest) and to_openapi (projection), error fidelity (HTTP_<status> per ADR-023), type definitions - http-mcp.md — from_mcp/to_mcp (feature-gated), streamable-HTTP-only - webtransport.md — h3/WebTransport handler, browser streaming path, HTTP/3 request vs WebTransport session distinguished at framing layer ADRs: - ADR-036 HTTP-to-Call Operation Mapping (Proposed) — direct path mapping; to_openapi is projection, not router (the load-bearing one-way door from Phase 0 DH-3) - ADR-037 MCP Stdio Transport Exclusion (Proposed) — streamable HTTP only; stdio is not built (RCE-vector security position) - ADR-038 HTTP/3 and WebTransport as First-Class HTTP Transports (Proposed) — corrects the Phase 0 DH-2 deferral framing; h3 is in scope, not deferred, per ADR-009 §'What this framework is NOT' - ADR-039 HTTP Server and Client Host Colocated in alknet-http (Proposed) — one crate for server + client host (shared HTTP deps, shared operation-spec->HTTP mapping) - ADR-003 Amendment 1 — clarifies alknet-call is a protocol-foundation crate (the alknet-http -> alknet-call dependency edge) Open questions (OQ-38, OQ-39, OQ-40 added under 'Theme: alknet-http'): - OQ-38 WebTransport relay-as-proxy scope (genuine scope question, not a deferral — the decision is made when the use case becomes concrete) - OQ-39 to_openapi published-spec versioning (one-way after first publication) - OQ-40 reqwest client config and connection pooling (two-way-door) Architecture README and overview updated with doc table, ADR table (036-039), current-state note, and crate graph (alknet-http -> alknet-call edge). Reviewed by architecture-reviewer subagent: 3 critical, 4 warning, 5 suggestion issues found and fixed (missing ADR-039, WebTransport stream routing conflation, undefined types, stale OQ-37 deferral language, README OQ table completeness, Bearer-only attribution, cross-references, ADR-038 ALPN quote, feature-gate placeholder, MCP temporal language).
This commit is contained in:
103
docs/architecture/crates/http/README.md
Normal file
103
docs/architecture/crates/http/README.md
Normal file
@@ -0,0 +1,103 @@
|
||||
---
|
||||
status: draft
|
||||
last_updated: 2026-06-29
|
||||
---
|
||||
|
||||
# alknet-http
|
||||
|
||||
HTTP interface for alknet: serves HTTP/1.1, HTTP/2, and HTTP/3 (WebTransport)
|
||||
on standard ALPNs, and hosts the HTTP-backed call-protocol adapters
|
||||
(`from_openapi`, `to_openapi`, `from_mcp`, `to_mcp`).
|
||||
|
||||
## Documents
|
||||
|
||||
| Document | Status | Description |
|
||||
|----------|--------|-------------|
|
||||
| [overview.md](overview.md) | draft | Crate purpose, two roles (server + client host), dependencies, adapter location map |
|
||||
| [http-server.md](http-server.md) | draft | `HttpAdapter` (`ProtocolHandler` for `h2`/`http/1.1`), axum over QUIC, Bearer auth, stealth, `/healthz` |
|
||||
| [http-adapters.md](http-adapters.md) | draft | `from_openapi` (reqwest client) and `to_openapi` (OpenAPI projection); no-env-vars invariant point |
|
||||
| [http-mcp.md](http-mcp.md) | draft | `from_mcp` / `to_mcp` (feature-gated), streamable-HTTP-only, stdio exclusion |
|
||||
| [webtransport.md](webtransport.md) | draft | `h3`/WebTransport handler — the browser streaming path |
|
||||
|
||||
## Applicable ADRs
|
||||
|
||||
| ADR | Title | Relevance |
|
||||
|-----|-------|-----------|
|
||||
| [001](../../decisions/001-alpn-protocol-dispatch.md) | ALPN-Based Protocol Dispatch | `HttpAdapter` registers on standard HTTP ALPNs |
|
||||
| [002](../../decisions/002-protocol-handler-trait.md) | ProtocolHandler Trait | `HttpAdapter` implements `ProtocolHandler` |
|
||||
| [003](../../decisions/003-crate-decomposition.md) | Crate Decomposition | `alknet-http` depends on `alknet-core` + `alknet-call` (protocol-foundation exception, Amendment 1) |
|
||||
| [004](../../decisions/004-auth-as-shared-core.md) | Auth as Shared Core | Bearer → `resolve_from_token` |
|
||||
| [007](../../decisions/007-bistream-type-definition.md) | BiStream Type Definition | `HttpAdapter` receives `Connection`, accepts a stream for hyper |
|
||||
| [010](../../decisions/010-alpn-router-and-endpoint.md) | ALPN Router and Endpoint | Stealth mode = HTTP handler on standard ALPNs |
|
||||
| [014](../../decisions/014-secret-material-flow-and-capability-injection.md) | Secret Material Flow | `from_openapi`/`from_mcp` are the credential injection point |
|
||||
| [015](../../decisions/015-privilege-model-and-authority-context.md) | Privilege Model | Adapter-registered ops are `Internal` by default |
|
||||
| [017](../../decisions/017-call-protocol-client-and-adapter-contract.md) | Call Protocol Client and Adapter Contract | `OperationAdapter` trait; `to_*` are projections; published-spec contract |
|
||||
| [022](../../decisions/022-handler-registration-provenance-and-composition-authority.md) | Handler Registration, Provenance, Composition Authority | `from_openapi`/`from_mcp` produce leaf bundles |
|
||||
| [023](../../decisions/023-operation-error-schemas.md) | Operation Error Schemas | `from_openapi`/`to_openapi` error fidelity; `HTTP_<status>` error codes |
|
||||
| [027](../../decisions/027-tls-identity-redesign-acme-rawkey-decoupling.md) | TLS Identity Redesign | Browsers require X.509; WebTransport requires X.509 |
|
||||
| [034](../../decisions/034-outgoing-only-x509-and-three-peer-roles.md) | Outgoing-Only X.509 and Three Peer Roles | Browsers are not alknet peers; WebTransport relay-as-proxy recorded |
|
||||
| [036](../../decisions/036-http-to-call-operation-mapping.md) | HTTP-to-Call Operation Mapping | Direct path mapping; `to_openapi` is projection, not router |
|
||||
| [037](../../decisions/037-mcp-stdio-transport-exclusion.md) | MCP Stdio Transport Exclusion | Streamable HTTP only; stdio not built |
|
||||
| [038](../../decisions/038-http3-and-webtransport-as-first-class.md) | HTTP/3 and WebTransport as First-Class HTTP Transports | `h3` in scope, not deferred |
|
||||
| [039](../../decisions/039-http-server-and-client-host-colocated.md) | HTTP Server and Client Host Colocated in alknet-http | One crate for server + client host (shared HTTP deps, shared mapping) |
|
||||
|
||||
## Relevant Open Questions
|
||||
|
||||
| OQ | Title | Status | Relevance |
|
||||
|----|-------|--------|-----------|
|
||||
| OQ-11 | Handler-level auth resolution observability | resolved | HTTP handler stores resolved identity on `Connection` via `set_identity` |
|
||||
| OQ-12 | TLS identity provisioning | resolved | Browsers require X.509 (gates the entire `h3` feature) |
|
||||
| OQ-13 | Operation path format | resolved | `/{service}/{op}` is the HTTP path (ADR-036) |
|
||||
| OQ-17 | Call protocol client and adapter contract | resolved | `OperationAdapter` trait; `to_*` projections |
|
||||
| OQ-24 | Operation error schemas | resolved | `from_openapi`/`to_openapi` error fidelity |
|
||||
| OQ-26 | OperationAdapter error type | resolved | `AdapterError` variants reused by HTTP adapters |
|
||||
| OQ-37 | X.509 outgoing-only / three peer roles | resolved | Browsers are not peers; hub with mixed fingerprints |
|
||||
| OQ-38 | WebTransport relay-as-proxy scope | open (scope, not deferral) | Does the proxy live in `alknet-http` or a separate relay crate? |
|
||||
| OQ-39 | `to_openapi` published-spec versioning | open | Versioning strategy for generated OpenAPI specs |
|
||||
| OQ-40 | reqwest client config and connection pooling | open | Two-way-door: pooling/retry config shape |
|
||||
|
||||
## Key Design Principles
|
||||
|
||||
1. **HTTP is both a server surface and a client transport for adapters.**
|
||||
Inbound HTTP (`h2`/`http/1.1`/`h3`) is served by `axum` over a QUIC
|
||||
stream; outbound HTTP (`from_openapi`/`from_mcp` forwarding) uses
|
||||
`reqwest`. Both directions share the same HTTP dependencies, which is
|
||||
why they live in one crate rather than being split. See
|
||||
[overview.md](overview.md).
|
||||
2. **The HTTP surface is a projection of the call protocol.** An HTTP
|
||||
request at `POST /fs/readFile` becomes a `call.requested` for
|
||||
`/fs/readFile`. The HTTP path IS the operation path; `to_openapi`
|
||||
describes this surface, it does not define a second one. See
|
||||
[ADR-036](../../decisions/036-http-to-call-operation-mapping.md).
|
||||
3. **Standard ALPNs, not alknet ALPNs.** `h2`, `http/1.1`, `h3` are
|
||||
IANA-registered ALPN strings. Any HTTP client (browser, curl, axios)
|
||||
connects without knowing about alknet — the TLS handshake negotiates
|
||||
`h2` or `http/1.1` normally. This is the stealth mapping (ADR-010).
|
||||
4. **`from_openapi`/`from_mcp` are the no-env-vars injection point.** The
|
||||
forwarding handlers read `context.capabilities`, not `std::env::var`.
|
||||
This is the architectural mechanism that makes aisdk's env-var reads
|
||||
unreachable. See ADR-014,
|
||||
[client-and-adapters.md](../call/client-and-adapters.md).
|
||||
5. **MCP streamable HTTP only; stdio is not built.** stdio = spawn
|
||||
arbitrary executable = RCE. Streamable HTTP is network-isolated,
|
||||
auth-gatable, and runs under alknet's auth model. See
|
||||
[ADR-037](../../decisions/037-mcp-stdio-transport-exclusion.md).
|
||||
6. **HTTP/3 + WebTransport is a first-class transport, not a deferral.**
|
||||
The browser streaming path uses QUIC streams directly. See
|
||||
[ADR-038](../../decisions/038-http3-and-webtransport-as-first-class.md).
|
||||
7. **`h3` requires X.509.** Browsers don't support RFC 7250 raw keys
|
||||
(ADR-027). A node serving WebTransport must have an X.509 identity.
|
||||
This is a browser limitation, not an alknet decision.
|
||||
|
||||
## References
|
||||
|
||||
- `docs/research/alknet-http/phase-0-findings.md` — Phase 0 research
|
||||
(directionally close; DH-2's deferral framing is corrected by ADR-038)
|
||||
- `docs/research/alknet-call-completion/gap-analysis.md` — adapter
|
||||
location map, no-env-vars invariant
|
||||
- `/workspace/@alkdev/operations/src/from_openapi.ts`,
|
||||
`/workspace/@alkdev/operations/src/from_mcp.ts` — TypeScript prior art
|
||||
- `/workspace/rust-sdk/` — MCP Rust SDK (rmcp v1.8.0); streamable HTTP
|
||||
transport examples
|
||||
- `/workspace/wtransport/` — pure-Rust WebTransport reference
|
||||
implementation (the `h3` feature's candidate dependency)
|
||||
326
docs/architecture/crates/http/http-adapters.md
Normal file
326
docs/architecture/crates/http/http-adapters.md
Normal file
@@ -0,0 +1,326 @@
|
||||
---
|
||||
status: draft
|
||||
last_updated: 2026-06-29
|
||||
---
|
||||
|
||||
# HTTP Adapters — from_openapi and to_openapi
|
||||
|
||||
The OpenAPI-direction adapters: `from_openapi` imports external HTTP APIs
|
||||
as call-protocol operations (reqwest-backed forwarding handlers), and
|
||||
`to_openapi` generates an OpenAPI spec from the local registry's
|
||||
`External` operations. This document covers both, the error fidelity
|
||||
(ADR-023), and the no-env-vars credential injection point.
|
||||
|
||||
## What
|
||||
|
||||
Two adapters, both in `alknet-http`:
|
||||
|
||||
1. **`from_openapi`** — parses an OpenAPI document, constructs a
|
||||
`HandlerRegistration` bundle per OpenAPI operation with a forwarding
|
||||
handler that calls the external HTTP endpoint via `reqwest`, and
|
||||
returns the bundles for registration in the `OperationRegistry`. The
|
||||
adapter implements `OperationAdapter` (the async trait from
|
||||
`alknet-call`, ADR-017 §5). Provenance is `FromOpenAPI` (leaf,
|
||||
`composition_authority: None`, `scoped_env: None`, `Internal` by
|
||||
default — ADR-015/022).
|
||||
2. **`to_openapi`** — generates an OpenAPI document from the local
|
||||
registry's `External` operations. A pure projection: it consumes the
|
||||
registry, it does not produce entries for it (ADR-017 §5 — the `to_*`
|
||||
adapters are outbound projections, not `OperationAdapter`
|
||||
implementations). Served at `GET /openapi.json` by the HTTP server.
|
||||
|
||||
### from_openapi
|
||||
|
||||
```rust
|
||||
pub struct FromOpenAPI {
|
||||
spec: OpenAPISpec,
|
||||
config: HttpServiceConfig,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl OperationAdapter for FromOpenAPI {
|
||||
async fn import(&self) -> Result<Vec<HandlerRegistration>, AdapterError>;
|
||||
}
|
||||
```
|
||||
|
||||
#### Type definitions
|
||||
|
||||
```rust
|
||||
/// A parsed OpenAPI document. The concrete type is a two-way-door
|
||||
/// implementation detail (openapiv3::OpenApi, a local alknet-http
|
||||
/// type, or a serde_json::Value-based parse); the one-way constraint is
|
||||
/// that `from_openapi` accepts a standard OpenAPI 3.x JSON/YAML doc and
|
||||
/// `to_openapi` produces one. Both directions share the same type.
|
||||
pub struct OpenAPISpec {
|
||||
pub info: OpenAPIInfo,
|
||||
pub paths: BTreeMap<String, PathItem>,
|
||||
pub components: Option<Components>,
|
||||
// ... OpenAPI 3.x fields as needed
|
||||
}
|
||||
|
||||
/// Configuration for an HTTP-backed adapter (`from_openapi`). Carries
|
||||
/// the base URL, auth credentials (from `Capabilities` at registration,
|
||||
/// not env vars — the no-env-vars invariant), and optional headers. The
|
||||
/// `auth` field is the auth scheme the external API expects (bearer,
|
||||
/// apiKey, basic); the credential itself is read from
|
||||
/// `OperationContext.capabilities` at call time, not stored here.
|
||||
pub struct HttpServiceConfig {
|
||||
pub namespace: String,
|
||||
pub base_url: String,
|
||||
pub auth: Option<HttpAuthScheme>,
|
||||
pub default_headers: HashMap<String, String>,
|
||||
}
|
||||
|
||||
pub enum HttpAuthScheme {
|
||||
Bearer, // Authorization: Bearer <token>
|
||||
ApiKey { header_name: String }, // e.g., X-API-Key: <key>
|
||||
Basic, // Authorization: Basic <credentials>
|
||||
}
|
||||
```
|
||||
|
||||
The adapter:
|
||||
|
||||
1. Parses the OpenAPI document (`OpenAPISpec` — `paths`, `components`,
|
||||
`$ref` resolution). On parse failure, returns
|
||||
`AdapterError::SchemaParse`. The TS prior art
|
||||
(`@alkdev/operations/src/from_openapi.ts`) shows the parsing patterns:
|
||||
`resolveRef` for `$ref`, `resolveRefsRecursive` for nested refs,
|
||||
`buildInputSchema` (parameters + request body → input JSON Schema),
|
||||
`buildOutputSchema` (200/201 response → output JSON Schema),
|
||||
`detectOperationType` (SSE response → `Subscription`, GET → `Query`,
|
||||
else `Mutation`).
|
||||
2. For each `(path, method, operation)` in `spec.paths`, constructs a
|
||||
`HandlerRegistration`:
|
||||
- `spec.name` = the `operationId` (or a generated
|
||||
`${method}_${path_parts}` name if `operationId` is absent — same
|
||||
normalization as the TS `normalizeOperationId`).
|
||||
- `spec.namespace` = the `config.namespace` (the importing
|
||||
deployment's name for the service, not the OpenAPI doc's `info.title`).
|
||||
- `spec.op_type` = `Query` / `Mutation` / `Subscription` (detected
|
||||
from the method + response content type, same as TS).
|
||||
- `spec.visibility` = `Internal` (adapter-registered ops are
|
||||
composition material, not directly callable from the wire —
|
||||
ADR-015).
|
||||
- `spec.input_schema` / `output_schema` = the JSON Schemas built
|
||||
from the OpenAPI parameters/responses.
|
||||
- `spec.error_schemas` = the `ErrorDefinition`s built from the
|
||||
non-2xx OpenAPI responses (ADR-023 §5 — see Error Fidelity below).
|
||||
- `spec.access_control` = `AccessControl::default()` (the adapter
|
||||
doesn't declare scopes; the composing handler that reaches the
|
||||
imported op gates access).
|
||||
- `handler` = a forwarding handler (see Forwarding Handler below).
|
||||
- `provenance` = `FromOpenAPI`, `composition_authority: None`,
|
||||
`scoped_env: None` (leaf — ADR-022).
|
||||
- `capabilities` = the credentials the forwarding handler needs (the
|
||||
bearer token / API key for the external HTTP endpoint, injected by
|
||||
the assembly layer at registration — see No-Env-Vars below).
|
||||
3. Returns the bundles. The caller (the assembly layer) registers them
|
||||
in the `OperationRegistry`.
|
||||
|
||||
### Forwarding handler
|
||||
|
||||
The forwarding handler is the `Arc<dyn Handler>` stored in the
|
||||
`HandlerRegistration`. At call time, it:
|
||||
|
||||
1. Reads the call input (`serde_json::Value`).
|
||||
2. Builds the outbound HTTP request:
|
||||
- URL path: substitutes path parameters (`{id}` → input value),
|
||||
appends query parameters from input fields not in the path.
|
||||
- Method: the OpenAPI operation's method.
|
||||
- Headers: `Content-Type: application/json` + the auth header built
|
||||
from `context.capabilities` (see No-Env-Vars below).
|
||||
- Body: the `body` field of the input (for `Mutation`/`Subscription`).
|
||||
3. Sends the request via the shared `reqwest::Client` (see HTTP Client
|
||||
below).
|
||||
4. For a `Query`/`Mutation`: parses the response body (JSON, text, or
|
||||
binary — same content-type branching as the TS `createHTTPOperation`),
|
||||
wraps it in a `ResponseEnvelope`, returns.
|
||||
5. For a `Subscription` (`text/event-stream` response): streams
|
||||
`call.responded` events as the SSE chunks arrive (same SSE parsing as
|
||||
the TS `parseSSEFrames`), then `call.completed` on stream end.
|
||||
6. On HTTP error (non-2xx): maps to the declared `ErrorDefinition` by
|
||||
HTTP status code (see Error Fidelity below), returns a `CallError`.
|
||||
|
||||
The handler is opaque to the `CallAdapter` — it's an `Arc<dyn Handler>`
|
||||
the registry dispatches. `alknet-call` never sees `reqwest`.
|
||||
|
||||
### HTTP client (reqwest)
|
||||
|
||||
`alknet-http` maintains a shared `reqwest::Client` (constructed once,
|
||||
reused across all `from_openapi`/`from_mcp` forwarding handlers). The
|
||||
client handles connection pooling, keep-alive, and TLS. The aisdk
|
||||
`core/client.rs` reference shows the pattern worth referencing: a shared
|
||||
client with `OnceLock<reqwest::Client>`, retry logic (exponential
|
||||
backoff, `Retry-After` header), and separate streaming vs non-streaming
|
||||
clients. `alknet-http` owns its HTTP client; it does not inherit aisdk's.
|
||||
|
||||
The retry/pooling config comes from `StaticConfig` or `DynamicConfig`
|
||||
(hot-reloadable). The credential injection happens per-request (from
|
||||
`OperationContext.capabilities`), not at client construction — the
|
||||
client is shared across all operations, the credentials are per-call.
|
||||
|
||||
The exact pooling/retry config is a two-way-door implementation detail
|
||||
(OQ-40); the one-way constraint is that `alknet-http` owns its `reqwest`
|
||||
client (no env-var-based client config, no shared global client).
|
||||
|
||||
### No-Env-Vars credential injection
|
||||
|
||||
The forwarding handler is the **credential injection point** for the
|
||||
no-env-vars architecture. The handler reads
|
||||
`context.capabilities.get("<service>")` (e.g., `"openai"`, `"vastai"`,
|
||||
`"github"`), extracts the credential, and injects it into the outbound
|
||||
HTTP request:
|
||||
|
||||
- Bearer token → `Authorization: Bearer <token>`.
|
||||
- API key → the header the OpenAPI spec declares (e.g., `X-API-Key:
|
||||
<key>`, or `Authorization: ApiKey <key>` — the `HTTPServiceConfig.auth`
|
||||
in the TS prior art shows the three auth types: `bearer`, `apiKey`,
|
||||
`basic`).
|
||||
- Basic auth → `Authorization: Basic <credentials>`.
|
||||
|
||||
The credential comes from `Capabilities`, which was populated by the
|
||||
dispatch path from the `HandlerRegistration.capabilities` bundle
|
||||
(ADR-022 §6), which was populated by the assembly layer from the vault
|
||||
(ADR-014). The handler never reads `std::env::var`. This is the
|
||||
spec-level invariant: no handler reads outbound credentials from any
|
||||
source other than `OperationContext.capabilities`. See
|
||||
[overview.md](overview.md) and
|
||||
[client-and-adapters.md](../call/client-and-adapters.md).
|
||||
|
||||
### to_openapi
|
||||
|
||||
```rust
|
||||
pub fn to_openapi(registry: &OperationRegistry) -> OpenAPISpec;
|
||||
```
|
||||
|
||||
`to_openapi` generates an OpenAPI document from the local registry's
|
||||
`External` operations:
|
||||
|
||||
1. For each `External` operation in the registry, generate an OpenAPI
|
||||
path entry:
|
||||
- Path: `/{service}/{op}` (the operation path, ADR-036 — the HTTP
|
||||
path IS the operation path).
|
||||
- Method: the operation's `OperationType` → HTTP method (`Query`→GET,
|
||||
`Mutation`→POST by default, `Subscription`→GET with
|
||||
`text/event-stream` response).
|
||||
- `operationId`: the operation name.
|
||||
- `parameters` / `requestBody` / `responses`: built from the
|
||||
operation's `input_schema` / `output_schema` / `error_schemas`.
|
||||
2. The `components.schemas` section holds the reusable schemas
|
||||
referenced by `$ref` from the paths.
|
||||
3. The `info` section carries the API title, version, and description.
|
||||
|
||||
This is a pure projection — it consumes the registry and produces a
|
||||
spec. It does not modify the registry; it does not register operations;
|
||||
it is not an `OperationAdapter`. The HTTP server serves the generated
|
||||
spec at `GET /openapi.json` (or a configured path).
|
||||
|
||||
### Error Fidelity (ADR-023)
|
||||
|
||||
`from_openapi` maps OpenAPI non-2xx response status codes to
|
||||
`ErrorDefinition`s (ADR-023 §5). The normative rule (review #002 W20):
|
||||
`from_openapi` must not produce error codes that collide with the five
|
||||
protocol-level codes (`NOT_FOUND`, `FORBIDDEN`, `INVALID_INPUT`,
|
||||
`INTERNAL`, `TIMEOUT`). The adapter prefixes imported error codes with
|
||||
`HTTP_` and the status number:
|
||||
|
||||
```rust
|
||||
// OpenAPI: 404: { schema: NotFoundError }
|
||||
// → ErrorDefinition { code: "HTTP_404", http_status: Some(404), schema: NotFoundError }
|
||||
```
|
||||
|
||||
`to_openapi` projects `error_schemas` back to OpenAPI response
|
||||
definitions:
|
||||
|
||||
```yaml
|
||||
responses:
|
||||
'200': { schema: <output_schema> }
|
||||
'404': { schema: <error_schemas[i].schema> } # where http_status = 404
|
||||
'429': { schema: <error_schemas[j].schema> } # where http_status = 429
|
||||
```
|
||||
|
||||
This makes the adapter contract from ADR-017 faithful on the error axis —
|
||||
no silent dropping of error contracts. See ADR-023.
|
||||
|
||||
## Why
|
||||
|
||||
`from_openapi` is how alknet composes external HTTP APIs (OpenAI,
|
||||
Anthropic, vast.ai, GitHub) into the call protocol. An operation
|
||||
imported via `from_openapi` is a first-class operation: it has a spec,
|
||||
it's discoverable via `services/list`, it can be composed by handlers,
|
||||
its errors are typed. The agent crate's LLM provider calls go through
|
||||
`from_openapi`-imported operations — that's how the no-env-vars
|
||||
invariant makes aisdk's env-var reads unreachable.
|
||||
|
||||
`to_openapi` is how external systems discover the alknet operation
|
||||
surface. An API gateway, a client generator, or a human developer reads
|
||||
the OpenAPI doc to learn what operations exist and how to call them.
|
||||
The generated spec is a compatibility contract (ADR-017 Consequences) —
|
||||
once published, the mapping is one-way.
|
||||
|
||||
## Constraints
|
||||
|
||||
- **`from_openapi`/`from_mcp` handlers read credentials from
|
||||
`OperationContext.capabilities`, not `std::env::var`.** This is the
|
||||
no-env-vars invariant (ADR-014). The handler implementations are
|
||||
verified against this invariant.
|
||||
- **`from_openapi`-registered ops are `Internal` by default.** They are
|
||||
composition material, not directly callable from the wire (ADR-015).
|
||||
The handler that composes them is `External`.
|
||||
- **`from_openapi` error codes are prefixed `HTTP_<status>`.** No
|
||||
collision with protocol-level codes (ADR-023, review #002 W20).
|
||||
- **`to_openapi` is a pure projection.** It consumes the registry, does
|
||||
not produce entries for it. Not an `OperationAdapter`.
|
||||
- **Published `to_openapi` specs are compatibility contracts.** The
|
||||
generated spec's versioning (tied to the registry's `External`
|
||||
operation set version) must be emitted so consumers can detect mapping
|
||||
changes (ADR-017 Consequences, OQ-39).
|
||||
- **`alknet-http` owns its `reqwest::Client`.** Shared across all
|
||||
forwarding handlers, constructed once. No env-var-based client config.
|
||||
Pooling/retry config is a two-way door (OQ-40).
|
||||
- **TLS for outbound calls uses the system trust store by default.**
|
||||
Standard HTTPS to external APIs (OpenAI, Anthropic). Custom CA bundle
|
||||
+ client certs are an optional config for self-hosted API gateways.
|
||||
This is a two-way-door implementation detail; the credential (API
|
||||
key/token) comes from `Capabilities`, the TLS trust comes from the
|
||||
system.
|
||||
|
||||
## Design Decisions
|
||||
|
||||
| Decision | ADR | Summary |
|
||||
|----------|-----|---------|
|
||||
| `from_openapi` is an `OperationAdapter` | [ADR-017](../../decisions/017-call-protocol-client-and-adapter-contract.md) | Async trait; produces `HandlerRegistration` bundles |
|
||||
| `to_openapi` is a projection, not an adapter | [ADR-017](../../decisions/017-call-protocol-client-and-adapter-contract.md) | Consumes the registry, doesn't produce entries |
|
||||
| Adapter-registered ops are `Internal` | [ADR-015](../../decisions/015-privilege-model-and-authority-context.md) | `from_openapi` ops are composition material |
|
||||
| `from_openapi` provenance is a leaf | [ADR-022](../../decisions/022-handler-registration-provenance-and-composition-authority.md) | `composition_authority: None`, `scoped_env: None` |
|
||||
| Error fidelity (`HTTP_<status>` codes) | [ADR-023](../../decisions/023-operation-error-schemas.md) | No collision with protocol codes; `to_openapi` projects back |
|
||||
| No-env-vars credential injection | [ADR-014](../../decisions/014-secret-material-flow-and-capability-injection.md) | Handler reads `context.capabilities`, not env vars |
|
||||
| HTTP path = operation path | [ADR-036](../../decisions/036-http-to-call-operation-mapping.md) | `to_openapi` paths mirror `/{service}/{op}` |
|
||||
|
||||
## Open Questions
|
||||
|
||||
See [open-questions.md](../../open-questions.md) for full details.
|
||||
|
||||
- **OQ-39** (open): `to_openapi` published-spec versioning — the
|
||||
versioning strategy for generated OpenAPI specs (tied to the
|
||||
registry's `External` operation set version). One-way after first
|
||||
publication.
|
||||
- **OQ-40** (open): reqwest client config and connection pooling —
|
||||
two-way-door: the exact pooling/retry config shape, hot-reloadable
|
||||
via `DynamicConfig`.
|
||||
|
||||
## References
|
||||
|
||||
- [ADR-017](../../decisions/017-call-protocol-client-and-adapter-contract.md)
|
||||
— `OperationAdapter` trait, `to_*` are projections
|
||||
- [ADR-023](../../decisions/023-operation-error-schemas.md) — error
|
||||
fidelity, `HTTP_<status>` prefix rule
|
||||
- [overview.md](overview.md) — adapter location map, no-env-vars
|
||||
invariant
|
||||
- [../call/client-and-adapters.md](../call/client-and-adapters.md) —
|
||||
`OperationAdapter` trait, `AdapterError` variants (OQ-26), no-env-vars
|
||||
invariant
|
||||
- `/workspace/@alkdev/operations/src/from_openapi.ts` — TypeScript prior
|
||||
art (parsing, SSE, auth headers, `createHTTPOperation`)
|
||||
- `/workspace/aisdk/src/core/client.rs` — HTTP client reference (pooling,
|
||||
retry, streaming vs non-streaming)
|
||||
245
docs/architecture/crates/http/http-mcp.md
Normal file
245
docs/architecture/crates/http/http-mcp.md
Normal file
@@ -0,0 +1,245 @@
|
||||
---
|
||||
status: draft
|
||||
last_updated: 2026-06-29
|
||||
---
|
||||
|
||||
# HTTP MCP — from_mcp and to_mcp
|
||||
|
||||
The MCP-direction adapters (feature-gated behind `mcp`): `from_mcp`
|
||||
imports remote MCP tools as call-protocol operations over streamable
|
||||
HTTP (reqwest client), and `to_mcp` exposes local operations as MCP
|
||||
tools over streamable HTTP (axum server). This document covers both, the
|
||||
rmcp integration, and the stdio exclusion (ADR-037).
|
||||
|
||||
## What
|
||||
|
||||
Two adapters, both in `alknet-http`, both behind the `mcp` feature gate:
|
||||
|
||||
1. **`from_mcp`** — discovers remote MCP tools via the MCP
|
||||
`tools/list` call over streamable HTTP, and registers each as a
|
||||
`HandlerRegistration` bundle with a forwarding handler that calls the
|
||||
remote tool via `tools/call`. Uses rmcp's
|
||||
`StreamableHttpClientTransport` (reqwest-based). Provenance is
|
||||
`FromMCP` (leaf, `composition_authority: None`, `scoped_env: None`,
|
||||
`Internal` by default — ADR-015/022). Implements `OperationAdapter`.
|
||||
2. **`to_mcp`** — exposes the local registry's `External` operations as
|
||||
MCP tools over streamable HTTP, using rmcp's `StreamableHttpService`
|
||||
(an axum-compatible tower service). An external MCP client (an editor,
|
||||
an AI tool) discovers and calls alknet operations through the MCP
|
||||
protocol. A pure projection (consumes the registry, does not produce
|
||||
entries — ADR-017 §5).
|
||||
|
||||
### Streamable HTTP only (ADR-037)
|
||||
|
||||
MCP defines two transports: streamable HTTP and stdio. **alknet-http
|
||||
supports only streamable HTTP.** Stdio is not built — it is the spawn-
|
||||
arbitrary-executable RCE vector that the rest of the architecture is
|
||||
designed to avoid (ADR-037). The `mcp` feature gate pulls in rmcp with
|
||||
the streamable HTTP transport features only; the stdio transport
|
||||
(`transport-child-process`) is not a dependency, not optional, not
|
||||
behind a separate feature.
|
||||
|
||||
If an operator wants a stdio-only MCP server, they run a small
|
||||
streamable-HTTP-to-stdio bridge themselves, outside alknet. The bridge
|
||||
is where the RCE risk lives, explicitly in the operator's hands. See
|
||||
ADR-037.
|
||||
|
||||
### from_mcp
|
||||
|
||||
```rust
|
||||
pub struct FromMCP {
|
||||
/// The MCP server's streamable HTTP endpoint URL.
|
||||
endpoint: String,
|
||||
/// Bearer token for the MCP server (from Capabilities at registration).
|
||||
auth_token: Option<String>,
|
||||
/// The importing deployment's name for this MCP server (becomes the
|
||||
/// operation namespace).
|
||||
namespace: String,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl OperationAdapter for FromMCP {
|
||||
async fn import(&self) -> Result<Vec<HandlerRegistration>, AdapterError>;
|
||||
}
|
||||
```
|
||||
|
||||
The adapter:
|
||||
|
||||
1. Connects to the MCP server's streamable HTTP endpoint using rmcp's
|
||||
`StreamableHttpClientTransport::from_uri(endpoint)` (the rmcp
|
||||
`streamable_http.rs` client example shows the pattern: `client_info
|
||||
.serve(transport).await`, then `client.list_tools()`,
|
||||
`client.call_tool()`). On connection failure, returns
|
||||
`AdapterError::DiscoveryFailed`; on 401, `AdapterError::Unauthorized`.
|
||||
2. Calls `tools/list` → the list of MCP tools (name, description,
|
||||
`inputSchema`, optional `outputSchema`).
|
||||
3. For each tool, constructs a `HandlerRegistration`:
|
||||
- `spec.name` = the tool name (or `namespace/tool_name` if a
|
||||
namespace prefix is configured — same local-naming sugar as
|
||||
`from_call`'s `FromCallConfig::namespace_prefix`, ADR-029 §5).
|
||||
- `spec.namespace` = the configured `namespace`.
|
||||
- `spec.op_type` = `Mutation` (MCP tools are call/response; the MCP
|
||||
spec doesn't have a native streaming/tool-subscription distinction
|
||||
— `tools/call` returns a result. If MCP adds a streaming-tool
|
||||
extension, a `Subscription` mapping would be added.)
|
||||
- `spec.visibility` = `Internal` (adapter-registered, ADR-015).
|
||||
- `spec.input_schema` = the tool's `inputSchema` (JSON Schema).
|
||||
- `spec.output_schema` = the tool's `outputSchema`, or
|
||||
`Type.Unknown()` if absent (the TS `from_mcp.ts` shows this
|
||||
fallback).
|
||||
- `spec.error_schemas` = the MCP tool's error description mapped to
|
||||
`ErrorDefinition` (ADR-023 — MCP tool definitions carry error
|
||||
descriptions; the adapter maps them).
|
||||
- `spec.access_control` = `AccessControl::default()`.
|
||||
- `handler` = a forwarding handler (see Forwarding Handler below).
|
||||
- `provenance` = `FromMCP`, `composition_authority: None`,
|
||||
`scoped_env: None` (leaf — ADR-022).
|
||||
- `capabilities` = the bearer token for the MCP server (injected by
|
||||
the assembly layer at registration — see No-Env-Vars below).
|
||||
4. Returns the bundles. The caller (the assembly layer) registers them
|
||||
in the `OperationRegistry`.
|
||||
|
||||
### Forwarding handler
|
||||
|
||||
At call time, the `from_mcp` forwarding handler:
|
||||
|
||||
1. Reads the call input (`serde_json::Value` — the tool arguments).
|
||||
2. Calls `client.call_tool({ name: tool_name, arguments: input })` via
|
||||
the rmcp client (the `streamable_http.rs` example shows
|
||||
`client.call_tool(CallToolRequestParams::new(name).with_arguments(...))`).
|
||||
3. On success: extracts `structuredContent` (if present) or maps the
|
||||
`content` blocks (the TS `mapMCPContentBlocks` shows the mapping:
|
||||
text/image/audio/resource/resource_link → `MCPContentBlock`),
|
||||
wraps in a `ResponseEnvelope`, returns.
|
||||
4. On `result.isError`: maps to a `CallError` with the MCP error content
|
||||
(the TS `from_mcp.ts` handler shows the error mapping), returns.
|
||||
5. The rmcp client connection is maintained for the lifetime of the
|
||||
registration (the MCP server is a persistent streamable HTTP
|
||||
endpoint, not a per-call connection).
|
||||
|
||||
The handler is opaque to the `CallAdapter` — `Arc<dyn Handler>` the
|
||||
registry dispatches. `alknet-call` never sees rmcp.
|
||||
|
||||
### to_mcp
|
||||
|
||||
```rust
|
||||
pub fn to_mcp_service(
|
||||
registry: Arc<OperationRegistry>,
|
||||
identity_provider: Arc<dyn IdentityProvider>,
|
||||
) -> StreamableHttpService<...>;
|
||||
```
|
||||
|
||||
`to_mcp` exposes the local registry's `External` operations as MCP tools
|
||||
over streamable HTTP, using rmcp's `StreamableHttpService` (an
|
||||
axum-compatible tower service). The rmcp
|
||||
`simple_auth_streamhttp.rs` server example shows the pattern:
|
||||
|
||||
```rust
|
||||
// From the rmcp example:
|
||||
let mcp_service: StreamableHttpService<Counter, LocalSessionManager> =
|
||||
StreamableHttpService::new(
|
||||
|| Ok(Counter::new()),
|
||||
LocalSessionManager::default().into(),
|
||||
StreamableHttpServerConfig::default(),
|
||||
);
|
||||
|
||||
let protected_mcp_router = Router::new()
|
||||
.nest_service("/mcp", mcp_service)
|
||||
.layer(middleware::from_fn_with_state(token_store, auth_middleware));
|
||||
```
|
||||
|
||||
`alknet-http`'s `to_mcp` follows the same pattern: the local operations
|
||||
are exposed as an MCP server (an rmcp `Service` impl that wraps the
|
||||
`OperationRegistry`), the `StreamableHttpService` nests into the axum
|
||||
`Router` at `/mcp`, and a Bearer auth middleware gates access (the
|
||||
`simple_auth_streamhttp.rs` `auth_middleware` + `extract_token` pattern).
|
||||
|
||||
The `to_mcp` service:
|
||||
|
||||
1. On MCP `tools/list`: returns the local registry's `External`
|
||||
operations as MCP tools (name, description, `inputSchema`).
|
||||
2. On MCP `tools/call`: dispatches to the `OperationRegistry::invoke()`
|
||||
— the same dispatch path the HTTP server uses for HTTP requests
|
||||
(ADR-036). The MCP tool call becomes a `call.requested` internally.
|
||||
The result is mapped back to the MCP `tools/call` response shape
|
||||
(`structuredContent` or `content` blocks).
|
||||
3. Auth: the Bearer middleware resolves the token via
|
||||
`IdentityProvider::resolve_from_token()`, same as the HTTP server's
|
||||
auth (ADR-004). The MCP client authenticates by bearer token; no
|
||||
`PeerId` (browsers and MCP clients are not alknet peers — ADR-034 §4).
|
||||
|
||||
### No-Env-Vars
|
||||
|
||||
The `from_mcp` forwarding handler reads the MCP server's bearer token
|
||||
from `context.capabilities` (the same injection path as `from_openapi`),
|
||||
not from `std::env::var`. The assembly layer injects the token at
|
||||
registration; the handler reads it per-call. This is the no-env-vars
|
||||
invariant (ADR-014, [overview.md](overview.md)).
|
||||
|
||||
## Why
|
||||
|
||||
MCP is the protocol editors and AI tools use to discover and call tools.
|
||||
`from_mcp` lets alknet compose external MCP servers (a remote tool
|
||||
server, a third-party MCP endpoint) into the call protocol — the same
|
||||
composition pattern as `from_openapi` and `from_call`. `to_mcp` lets
|
||||
external MCP clients (an editor, an AI tool) discover and call alknet
|
||||
operations through the MCP protocol, without those clients needing to
|
||||
speak EventEnvelope.
|
||||
|
||||
The streamable-HTTP-only constraint (ADR-037) is a security position:
|
||||
alknet does not import the MCP stdio RCE vector. The streamable HTTP
|
||||
path is network-isolated, auth-gatable, and runs under alknet's
|
||||
auth/identity/capabilities machinery — the same machinery that gates
|
||||
every other HTTP request.
|
||||
|
||||
## Constraints
|
||||
|
||||
- **Streamable HTTP only.** Stdio is not built (ADR-037). The `mcp`
|
||||
feature pulls in rmcp with streamable HTTP transport features only.
|
||||
- **`from_mcp`-registered ops are `Internal` by default.** Composition
|
||||
material, not directly callable from the wire (ADR-015).
|
||||
- **`from_mcp` handlers read credentials from
|
||||
`OperationContext.capabilities`.** No env vars (ADR-014).
|
||||
- **`to_mcp` is a pure projection.** Consumes the registry, does not
|
||||
produce entries. Not an `OperationAdapter`.
|
||||
- **MCP clients are not alknet peers.** A browser or MCP client
|
||||
connecting to `to_mcp` authenticates by bearer token, gets no
|
||||
`PeerId`, is not in the peer graph (ADR-034 §4).
|
||||
- **The `mcp` feature is optional.** A deployment that doesn't need MCP
|
||||
doesn't compile rmcp. The default feature set is `h2` + `http1`.
|
||||
|
||||
## Design Decisions
|
||||
|
||||
| Decision | ADR | Summary |
|
||||
|----------|-----|---------|
|
||||
| MCP stdio transport excluded | [ADR-037](../../decisions/037-mcp-stdio-transport-exclusion.md) | Streamable HTTP only; stdio is not built |
|
||||
| `from_mcp` is an `OperationAdapter` | [ADR-017](../../decisions/017-call-protocol-client-and-adapter-contract.md) | Async trait; produces `HandlerRegistration` bundles |
|
||||
| `to_mcp` is a projection | [ADR-017](../../decisions/017-call-protocol-client-and-adapter-contract.md) | Consumes the registry, doesn't produce entries |
|
||||
| Adapter-registered ops are `Internal` | [ADR-015](../../decisions/015-privilege-model-and-authority-context.md) | `from_mcp` ops are composition material |
|
||||
| `from_mcp` provenance is a leaf | [ADR-022](../../decisions/022-handler-registration-provenance-and-composition-authority.md) | `composition_authority: None`, `scoped_env: None` |
|
||||
| Error fidelity | [ADR-023](../../decisions/023-operation-error-schemas.md) | MCP tool errors mapped to `ErrorDefinition`s |
|
||||
| No-env-vars credential injection | [ADR-014](../../decisions/014-secret-material-flow-and-capability-injection.md) | Handler reads `context.capabilities`, not env vars |
|
||||
| MCP clients are not alknet peers | [ADR-034](../../decisions/034-outgoing-only-x509-and-three-peer-roles.md) | Bearer token, no `PeerId` |
|
||||
|
||||
## Open Questions
|
||||
|
||||
See [open-questions.md](../../open-questions.md) for full details.
|
||||
|
||||
- **OQ-40** (open): reqwest client config — the shared `reqwest::Client`
|
||||
used by `from_mcp` (same client as `from_openapi`).
|
||||
|
||||
## References
|
||||
|
||||
- [ADR-037](../../decisions/037-mcp-stdio-transport-exclusion.md) — the
|
||||
stdio exclusion this document enforces
|
||||
- [overview.md](overview.md) — adapter location map, feature gates
|
||||
- [../call/client-and-adapters.md](../call/client-and-adapters.md) —
|
||||
`OperationAdapter` trait, `AdapterError` variants
|
||||
- `/workspace/rust-sdk/` — MCP Rust SDK (rmcp v1.8.0); streamable HTTP
|
||||
transport
|
||||
- `/workspace/rust-sdk/examples/servers/src/simple_auth_streamhttp.rs`
|
||||
— streamable HTTP MCP server with Bearer auth (the `to_mcp` pattern)
|
||||
- `/workspace/rust-sdk/examples/clients/src/streamable_http.rs` —
|
||||
streamable HTTP MCP client (the `from_mcp` pattern)
|
||||
- `/workspace/@alkdev/operations/src/from_mcp.ts` — TypeScript prior art
|
||||
(`createMCPClient`, `mapMCPContentBlocks`, the `MCPClientLoader`)
|
||||
292
docs/architecture/crates/http/http-server.md
Normal file
292
docs/architecture/crates/http/http-server.md
Normal file
@@ -0,0 +1,292 @@
|
||||
---
|
||||
status: draft
|
||||
last_updated: 2026-06-29
|
||||
---
|
||||
|
||||
# HTTP Server
|
||||
|
||||
The `HttpAdapter` — the `ProtocolHandler` for `h2` and `http/1.1` (and
|
||||
`h3`, covered in [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, 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](../core/auth.md)) and an
|
||||
`Arc<OperationRegistry>` (for dispatching HTTP requests to call-protocol
|
||||
operations). It implements `ProtocolHandler` for the standard HTTP ALPNs.
|
||||
|
||||
```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,
|
||||
}
|
||||
|
||||
/// The stealth decoy surface for paths that are not registered
|
||||
/// operations (and not `/healthz`, `/openapi.json`, 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](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 `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 call-protocol projection routes (`POST /{service}/{op}` →
|
||||
`call.requested` dispatch — ADR-036).
|
||||
- `GET /healthz` (raw route, no auth, no call protocol).
|
||||
- `GET /openapi.json` (serves the `to_openapi` projection).
|
||||
- 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 WebTransport
|
||||
(`h3`), the subscription projects directly onto a WebTransport
|
||||
bidirectional stream — no SSE framing (see [webtransport.md](webtransport.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). 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<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`, 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.** `POST /fs/readFile` →
|
||||
`call.requested` for `fs/readFile`. No second routing table. See
|
||||
ADR-036.
|
||||
- **`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.
|
||||
- **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](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](../../decisions/036-http-to-call-operation-mapping.md) | `POST /{service}/{op}` → `call.requested` |
|
||||
| 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) |
|
||||
| `h3` is first-class (not deferred) | [ADR-038](../../decisions/038-http3-and-webtransport-as-first-class.md) | The `h3` ALPN handler lives in this crate |
|
||||
| Error mapping (call codes → HTTP status) | [ADR-023](../../decisions/023-operation-error-schemas.md) | Protocol/operation codes distinct; `HTTP_<status>` 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** (open): reqwest client config and connection pooling —
|
||||
two-way-door config shape for 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-038](../../decisions/038-http3-and-webtransport-as-first-class.md)
|
||||
— the `h3`/WebTransport companion to this server
|
||||
- [overview.md](overview.md) — crate overview, adapter location map
|
||||
- [webtransport.md](webtransport.md) — the `h3` ALPN handler
|
||||
- [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
|
||||
243
docs/architecture/crates/http/overview.md
Normal file
243
docs/architecture/crates/http/overview.md
Normal file
@@ -0,0 +1,243 @@
|
||||
---
|
||||
status: draft
|
||||
last_updated: 2026-06-29
|
||||
---
|
||||
|
||||
# alknet-http — Overview
|
||||
|
||||
The HTTP interface crate: serves inbound HTTP on standard ALPNs and hosts
|
||||
the HTTP-backed call-protocol adapters. This document covers the crate's
|
||||
two roles, its dependency edges, and the adapter location map. Component
|
||||
details are in the sibling documents.
|
||||
|
||||
## What
|
||||
|
||||
`alknet-http` is the HTTP protocol handler for the ALPN-as-service
|
||||
architecture. It serves two roles in one crate:
|
||||
|
||||
1. **HTTP server** — a `ProtocolHandler` (`HttpAdapter`) that accepts
|
||||
HTTP/2, HTTP/1.1, and HTTP/3 (WebTransport) connections on the
|
||||
standard IANA ALPNs (`h2`, `http/1.1`, `h3`). It serves REST APIs, the
|
||||
`to_openapi`/`to_mcp` projections of local call-protocol operations,
|
||||
the `/healthz` operational endpoint, and the decoy surface for
|
||||
stealth mode.
|
||||
2. **HTTP client host** — the home of the HTTP-transport-backed call
|
||||
adapters: `from_openapi` (import external HTTP APIs as call
|
||||
operations, using `reqwest` for outbound calls) and `from_mcp` (import
|
||||
remote MCP tools over streamable HTTP, using `reqwest`). The reverse
|
||||
projections `to_openapi` (generate an OpenAPI doc from the local
|
||||
registry's `External` operations) and `to_mcp` (expose local ops as
|
||||
MCP tools over streamable HTTP, using `axum`) also live here.
|
||||
|
||||
Both directions share the same HTTP dependencies (`axum` for serving,
|
||||
`reqwest` for calling out), which is why they live in one crate rather
|
||||
than being split into a server crate and a client crate. See
|
||||
[ADR-039](../../decisions/039-http-server-and-client-host-colocated.md)
|
||||
for the full rationale.
|
||||
|
||||
## Why
|
||||
|
||||
The crate's purpose is to be the HTTP interface library for downstream
|
||||
crates that need to expose an HTTP interface. A downstream consumer (the
|
||||
CLI binary, a hub deployment, a browser-facing service) wires
|
||||
`HttpAdapter` into the `HandlerRegistry` for the standard HTTP ALPNs and
|
||||
gets a full HTTP surface: REST projection of the call protocol, OpenAPI
|
||||
discovery, MCP tool exposure, and WebTransport for browsers.
|
||||
|
||||
The key architectural insight that shapes the crate: **HTTP is both a
|
||||
server surface and a client transport for adapters.** The server side
|
||||
serves HTTP to external clients (browsers, curl, axios); the client side
|
||||
makes outbound HTTP calls to external APIs (OpenAI, Anthropic, vast.ai)
|
||||
through the `from_openapi`/`from_mcp` forwarding handlers. Both
|
||||
directions share HTTP dependencies and HTTP-specific concerns (TLS,
|
||||
headers, streaming, SSE), so they belong in one crate. See
|
||||
[ADR-039](../../decisions/039-http-server-and-client-host-colocated.md)
|
||||
for the colocation decision.
|
||||
|
||||
## Dependencies
|
||||
|
||||
```
|
||||
alknet-http
|
||||
├── alknet-core (ProtocolHandler, Connection, AuthContext, IdentityProvider, Capabilities)
|
||||
├── alknet-call (OperationAdapter, OperationSpec, Handler, HandlerRegistration,
|
||||
│ OperationRegistry, AdapterError, OperationProvenance)
|
||||
├── axum (HTTP server — Router, extractors, middleware)
|
||||
├── reqwest (HTTP client — from_openapi/from_mcp forwarding)
|
||||
├── hyper (HTTP/1.1 + HTTP/2 framing; axum is built on hyper)
|
||||
├── wtransport (HTTP/3 + WebTransport — feature-gated behind `h3`)
|
||||
└── rmcp (MCP streamable HTTP — feature-gated behind `mcp`)
|
||||
```
|
||||
|
||||
### The `alknet-call` dependency (ADR-003 Amendment 1)
|
||||
|
||||
`alknet-http` depends on `alknet-call`. ADR-003's rule is "no handler
|
||||
crate depends on another handler crate," but `alknet-call` is both a
|
||||
handler (it implements `ProtocolHandler` on `alknet/call`) *and* the
|
||||
protocol-foundation crate that `alknet-agent`, `alknet-napi`, and now
|
||||
`alknet-http` consume. `alknet-http` depending on `alknet-call` is
|
||||
"HTTP uses the call protocol types" (`OperationSpec`, `Handler`,
|
||||
`HandlerRegistration`, `OperationAdapter`), not "HTTP depends on SSH."
|
||||
See [ADR-003 Amendment 1](../../decisions/003-crate-decomposition.md).
|
||||
|
||||
`alknet-call` stays lean — it has no `reqwest`, no `axum`, no HTTP
|
||||
dependencies. The `from_openapi`/`from_mcp` forwarding handlers are
|
||||
opaque `Arc<dyn Handler>` from the registry's perspective: constructed by
|
||||
`alknet_http::from_openapi()` at registration time, stored in
|
||||
`HandlerRegistration`, dispatched by the `CallAdapter` which doesn't
|
||||
know `reqwest` is involved.
|
||||
|
||||
## ALPNs
|
||||
|
||||
| ALPN | Handler | Transport | Browser? |
|
||||
|------|---------|-----------|----------|
|
||||
| `http/1.1` | `HttpAdapter` | HTTP/1.1 over QUIC stream | No |
|
||||
| `h2` | `HttpAdapter` | HTTP/2 over QUIC stream | No |
|
||||
| `h3` | `HttpAdapter` | HTTP/3 / WebTransport | Yes (X.509 required) |
|
||||
|
||||
These are standard IANA ALPN strings, not `alknet/`-prefixed. Any HTTP
|
||||
client connects without knowing about alknet — the TLS handshake
|
||||
negotiates `h2` or `http/1.1` normally, and the `HttpAdapter` serves
|
||||
HTTP. This is the stealth mapping (ADR-010): clients that don't offer
|
||||
alknet ALPNs get the HTTP handler, just like port scanners in stealth
|
||||
mode.
|
||||
|
||||
The `HttpAdapter` registers for all three ALPNs (when the corresponding
|
||||
features are enabled). The endpoint's `HandlerRegistry` maps each ALPN to
|
||||
the same `HttpAdapter` instance; the handler branches on
|
||||
`connection.remote_alpn()` to pick the right framing.
|
||||
|
||||
## Adapter Location Map
|
||||
|
||||
The decomposition principle (settled in
|
||||
[client-and-adapters.md](../call/client-and-adapters.md)): the adapter
|
||||
trait lives where the types live (`alknet-call`); the adapter
|
||||
implementations live where their transport dependencies live.
|
||||
|
||||
```
|
||||
alknet-call (lean — no HTTP client, no HTTP server)
|
||||
├── OperationAdapter trait (the contract — async, ADR-017 §5)
|
||||
├── from_call (QUIC — discovers remote ops via call protocol)
|
||||
├── from_jsonschema (pure parse — caller fetches the doc, passes it in)
|
||||
└── CallClient (outbound connection opener)
|
||||
|
||||
alknet-http (owns HTTP server + HTTP client)
|
||||
├── HttpAdapter (axum server — inbound HTTP on h2/http1.1/h3)
|
||||
├── from_openapi (parse OpenAPI doc + reqwest forwarding handler)
|
||||
├── to_openapi (generate OpenAPI doc from local registry)
|
||||
├── from_mcp (feature-gated) (import remote MCP tools over streamable HTTP — reqwest)
|
||||
└── to_mcp (feature-gated) (expose local ops as MCP tools over streamable HTTP — axum)
|
||||
```
|
||||
|
||||
`alknet-call` never sees the HTTP client. The `from_openapi`/`from_mcp`
|
||||
forwarding handlers are opaque `Arc<dyn Handler>` from the registry's
|
||||
perspective. `alknet-call` stays lean; `alknet-http` owns both HTTP
|
||||
directions.
|
||||
|
||||
## Feature Gates
|
||||
|
||||
```toml
|
||||
[features]
|
||||
default = ["h2", "http1"] # the non-browser HTTP surface
|
||||
h3 = ["dep:wtransport"] # HTTP/3 + WebTransport (browser path; X.509 required)
|
||||
mcp = ["dep:rmcp"] # from_mcp / to_mcp (streamable HTTP only — ADR-037)
|
||||
```
|
||||
|
||||
- `h2` + `http1` (default): the `axum` + `hyper` HTTP/1.1 + HTTP/2
|
||||
server. This is the surface non-browser clients use.
|
||||
- `h3`: the `wtransport` (or quinn HTTP/3 extension) dependency. Adds
|
||||
the `h3` ALPN handler and the WebTransport streaming path. See
|
||||
[webtransport.md](webtransport.md) and
|
||||
[ADR-038](../../decisions/038-http3-and-webtransport-as-first-class.md).
|
||||
- `mcp`: the `rmcp` dependency with streamable HTTP transport features
|
||||
only. Adds `from_mcp`/`to_mcp`. See [http-mcp.md](http-mcp.md) and
|
||||
[ADR-037](../../decisions/037-mcp-stdio-transport-exclusion.md).
|
||||
|
||||
A deployment that only needs the REST surface (no browsers, no MCP) uses
|
||||
the default features. A browser-facing hub enables `h3`. A deployment
|
||||
that wants MCP tool import/export enables `mcp`.
|
||||
|
||||
## The No-Env-Vars Invariant
|
||||
|
||||
The `from_openapi`/`from_mcp` forwarding handlers are the **credential
|
||||
injection point** for the no-env-vars architecture. The path (from the
|
||||
gap analysis):
|
||||
|
||||
```
|
||||
vault → assembly layer → Capabilities → HandlerRegistration.capabilities
|
||||
→ OperationContext.capabilities → from_openapi handler reads
|
||||
context.capabilities.get("openai") → injects into HTTP Authorization
|
||||
header → reqwest request goes out with vault-derived credential
|
||||
```
|
||||
|
||||
This makes aisdk's `std::env::var("OPENAI_API_KEY")` reads unreachable —
|
||||
the assembly layer never calls `Default::default()` on a provider; it
|
||||
constructs them with vault-derived credentials, or routes HTTP calls
|
||||
through `from_openapi` operations that carry the credential in
|
||||
`Capabilities`.
|
||||
|
||||
**This is a spec-level invariant**: no handler reads outbound
|
||||
credentials from any source other than `OperationContext.capabilities`.
|
||||
The `from_openapi`/`from_mcp` implementations in `alknet-http` are
|
||||
verified against this invariant. See ADR-014 and
|
||||
[client-and-adapters.md](../call/client-and-adapters.md).
|
||||
|
||||
## Architecture (component pointers)
|
||||
|
||||
- **[http-server.md](http-server.md)** — the `HttpAdapter` for `h2`/
|
||||
`http/1.1`: how axum is run over a QUIC bidirectional stream, Bearer
|
||||
auth resolution, the `/healthz` raw route, stealth decoy, and the
|
||||
HTTP-to-call dispatch (ADR-036).
|
||||
- **[http-adapters.md](http-adapters.md)** — `from_openapi` (parse
|
||||
OpenAPI, build forwarding handlers with `reqwest`) and `to_openapi`
|
||||
(generate an OpenAPI doc from the registry's `External` operations).
|
||||
Error fidelity per ADR-023.
|
||||
- **[http-mcp.md](http-mcp.md)** — `from_mcp`/`to_mcp` (feature-gated),
|
||||
streamable HTTP only (ADR-037), the rmcp integration.
|
||||
- **[webtransport.md](webtransport.md)** — the `h3` ALPN handler,
|
||||
WebTransport session/stream handling, the browser streaming path
|
||||
(ADR-038).
|
||||
|
||||
## Design Decisions
|
||||
|
||||
| Decision | ADR | Summary |
|
||||
|----------|-----|---------|
|
||||
| HTTP-to-call operation mapping | [ADR-036](../../decisions/036-http-to-call-operation-mapping.md) | Direct path mapping; `to_openapi` is projection, not router |
|
||||
| MCP stdio transport exclusion | [ADR-037](../../decisions/037-mcp-stdio-transport-exclusion.md) | Streamable HTTP only; stdio is not built (RCE vector) |
|
||||
| HTTP/3 + WebTransport first-class | [ADR-038](../../decisions/038-http3-and-webtransport-as-first-class.md) | `h3` in scope, not deferred; browser streaming uses QUIC streams |
|
||||
| HTTP server + client host colocated | [ADR-039](../../decisions/039-http-server-and-client-host-colocated.md) | One crate for server + adapters (shared HTTP deps, shared mapping) |
|
||||
| `alknet-call` is protocol-foundation | [ADR-003](../../decisions/003-crate-decomposition.md) Am. 1 | `alknet-http` depends on `alknet-call` (types, not peer handler) |
|
||||
| Bearer auth via `resolve_from_token` | [ADR-004](../../decisions/004-auth-as-shared-core.md) | HTTP handler credential source + resolution (settled) |
|
||||
| Stealth mode = HTTP handler on standard ALPNs | [ADR-010](../../decisions/010-alpn-router-and-endpoint.md) | Decoy for unknown paths (settled) |
|
||||
| Adapter-registered ops are `Internal` | [ADR-015](../../decisions/015-privilege-model-and-authority-context.md) | `from_openapi`/`from_mcp` produce `Internal` leaves (settled) |
|
||||
| `OperationAdapter` trait is async | [ADR-017](../../decisions/017-call-protocol-client-and-adapter-contract.md) | HTTP adapters implement the async trait (settled) |
|
||||
| `to_*` adapters are projections | [ADR-017](../../decisions/017-call-protocol-client-and-adapter-contract.md) | `to_openapi`/`to_mcp` consume the registry, don't produce entries (settled) |
|
||||
| Error schema fidelity | [ADR-023](../../decisions/023-operation-error-schemas.md) | `from_openapi` maps HTTP status → `HTTP_<status>` codes; `to_openapi` projects back (settled) |
|
||||
| Browsers require X.509 | [ADR-027](../../decisions/027-tls-identity-redesign-acme-rawkey-decoupling.md) | `h3`/WebTransport needs X.509 (settled) |
|
||||
| Browsers are not alknet peers | [ADR-034](../../decisions/034-outgoing-only-x509-and-three-peer-roles.md) | Browser over WebTransport/HTTPS = bearer token, no `PeerId` (settled) |
|
||||
|
||||
## Open Questions
|
||||
|
||||
See [open-questions.md](../../open-questions.md) for full details.
|
||||
|
||||
- **OQ-13** (resolved): Operation path format `/{service}/{op}` — the
|
||||
HTTP path.
|
||||
- **OQ-26** (resolved): `AdapterError` variants — reused by HTTP
|
||||
adapters; `#[non_exhaustive]` allows extension.
|
||||
- **OQ-37** (resolved): Browsers are not peers; `h3` hub is a
|
||||
mixed-fingerprint `PeerEntry`.
|
||||
- **OQ-38** (open, scope): WebTransport relay-as-proxy — does the proxy
|
||||
live in `alknet-http` or a separate relay crate?
|
||||
- **OQ-39** (open): `to_openapi` published-spec versioning — versioning
|
||||
strategy for generated OpenAPI specs.
|
||||
- **OQ-40** (open): reqwest client config and connection pooling —
|
||||
two-way-door config shape.
|
||||
|
||||
## References
|
||||
|
||||
- `docs/research/alknet-http/phase-0-findings.md` — Phase 0 research
|
||||
- `docs/research/alknet-call-completion/gap-analysis.md` — adapter
|
||||
location map, no-env-vars invariant
|
||||
- `/workspace/@alkdev/operations/src/from_openapi.ts`,
|
||||
`/workspace/@alkdev/operations/src/from_mcp.ts` — TypeScript prior art
|
||||
- `/workspace/rust-sdk/` — MCP Rust SDK (rmcp); streamable HTTP examples
|
||||
- `/workspace/wtransport/` — pure-Rust WebTransport reference
|
||||
232
docs/architecture/crates/http/webtransport.md
Normal file
232
docs/architecture/crates/http/webtransport.md
Normal file
@@ -0,0 +1,232 @@
|
||||
---
|
||||
status: draft
|
||||
last_updated: 2026-06-29
|
||||
---
|
||||
|
||||
# WebTransport — the h3 ALPN handler
|
||||
|
||||
The `HttpAdapter` registration for the `h3` ALPN: HTTP/3 and
|
||||
WebTransport. This document covers the WebTransport session/stream
|
||||
handling, the browser streaming path, and the relationship to the `h2`/
|
||||
`http/1.1` server. The `h3` support is a first-class transport, not a
|
||||
deferral (ADR-038).
|
||||
|
||||
## What
|
||||
|
||||
The `h3` ALPN handler is the same `HttpAdapter` instance that serves
|
||||
`h2`/`http/1.1`, registered for the `h3` ALPN when the `h3` feature is
|
||||
enabled. It serves two things on a single `h3` connection:
|
||||
|
||||
1. **HTTP/3 requests** — the standard HTTP/3 over QUIC framing. An
|
||||
HTTP/3 request is dispatched through the same axum `Router` as `h2`/
|
||||
`http/1.1` requests (ADR-036 — the HTTP path IS the operation path).
|
||||
From the axum router's perspective, an HTTP/3 request is just
|
||||
another HTTP request; the framing difference is handled below the
|
||||
router.
|
||||
2. **WebTransport sessions** — the browser streaming path. A WebTransport
|
||||
session is a long-lived connection over which the browser opens
|
||||
bidirectional and unidirectional streams. A WebTransport stream that
|
||||
targets the call protocol is handed to the call protocol's dispatch
|
||||
loop directly — a WebTransport bidirectional stream is a QUIC
|
||||
bidirectional stream, the same stream type the call protocol already
|
||||
speaks (ADR-012).
|
||||
|
||||
### Why h3 is a first-class transport
|
||||
|
||||
WebTransport is the browser streaming transport. QUIC streams are cheap
|
||||
(multiplexed over one connection, no head-of-line blocking), and
|
||||
WebTransport is supported in major browsers. The call protocol's
|
||||
subscription/streaming model maps onto WebTransport streams with no
|
||||
translation loss — a `call.responded` stream over a WebTransport
|
||||
bidirectional stream is the native representation, not an SSE
|
||||
translation (which is the projection for `h2`/`http/1.1` clients per
|
||||
ADR-036).
|
||||
|
||||
The Phase 0 research framing ("defer h3/WebTransport past v1") was a
|
||||
residual of the "two-way door as deferral" anti-pattern (ADR-009 §"What
|
||||
this framework is NOT"). WebTransport is in scope, in this crate, as a
|
||||
first-class transport. See ADR-038.
|
||||
|
||||
## Architecture
|
||||
|
||||
### The h3 handler entry
|
||||
|
||||
The `HttpAdapter::handle()` method for the `h3` ALPN drives two
|
||||
distinct stream types, distinguished at the HTTP/3 framing layer (not by
|
||||
peeking application bytes):
|
||||
|
||||
1. **HTTP/3 request streams** — standard HTTP/3 GET/POST carrying
|
||||
`:method`/`:path`. These are the same request model as `h2`/
|
||||
`http/1.1`, just over HTTP/3 framing. Dispatched through the axum
|
||||
`Router` (same router as `h2`/`http/1.1`, ADR-036). An HTTP/3 request
|
||||
is never a WebTransport stream — the stream type is set by the
|
||||
HTTP/3 frame that opens it.
|
||||
2. **WebTransport sessions** — opened by a browser's
|
||||
`new WebTransport(url)` call, which triggers an HTTP/3 extended
|
||||
CONNECT request. The handler accepts the session (the `wtransport`
|
||||
crate's `Endpoint::server(config)?.accept().await.await?.accept()
|
||||
.await?` pattern, or the quinn HTTP/3 endpoint's WebTransport
|
||||
extension — the exact library is a two-way-door implementation
|
||||
detail, ADR-038). Within an established session, the browser opens
|
||||
bidirectional streams via `transport.createBidirectionalStream()`;
|
||||
the handler accepts each via `session.accept_bi()`.
|
||||
|
||||
The two stream types are not disambiguated by "reading the first frame"
|
||||
— they are distinguished by the HTTP/3 frame type that opens them
|
||||
(regular request headers vs. extended CONNECT). The "first frame"
|
||||
routing below applies *within* a WebTransport session, not between an
|
||||
HTTP/3 request and a WebTransport stream.
|
||||
|
||||
### WebTransport session and stream handling
|
||||
|
||||
Once a WebTransport session is established (via extended CONNECT), the
|
||||
browser creates bidirectional streams within it. The handler accepts
|
||||
each stream (`session.accept_bi()`) and reads the first frame to
|
||||
determine the sub-protocol:
|
||||
|
||||
- **Call-protocol `EventEnvelope`** — the stream is a call-protocol
|
||||
stream. The handler hands the `(SendStream, RecvStream)` pair to the
|
||||
call protocol's `Dispatcher` (see [../call/call-protocol.md](../call/call-protocol.md)
|
||||
for `EventEnvelope` and [../call/client-and-adapters.md](../call/client-and-adapters.md)
|
||||
§"Shared Dispatcher" for the `Dispatcher` — the same dispatch loop
|
||||
the `CallAdapter` uses for `alknet/call` connections, ADR-012,
|
||||
stream-agnostic correlation). The browser speaks the EventEnvelope
|
||||
wire format directly over the WebTransport stream.
|
||||
- **Other sub-protocols** — a session may carry other framing
|
||||
conventions (e.g., a future WT-native RPC framing). The session's
|
||||
purpose is declared at CONNECT time (by path/origin), so the handler
|
||||
knows which sub-protocol to expect; the first-frame tag is a
|
||||
belt-and-suspenders disambiguator for sessions that multiplex
|
||||
sub-protocols. For the call-protocol session, the first frame is an
|
||||
`EventEnvelope` JSON object; the handler dispatches accordingly.
|
||||
|
||||
The browser's `WebTransport` JS API is the client side of this:
|
||||
`new WebTransport('https://hub.example.com')` →
|
||||
`transport.createBidirectionalStream()` → write an `EventEnvelope` frame
|
||||
→ read `call.responded` frames. No SSE translation, no HTTP framing —
|
||||
the call protocol speaks directly over the WebTransport stream.
|
||||
|
||||
### Subscription projection (native, not SSE)
|
||||
|
||||
A `Subscription` operation served over WebTransport projects its
|
||||
`call.responded` stream directly onto the WebTransport bidirectional
|
||||
stream — each `call.responded` event is a frame on the stream, no SSE
|
||||
`data:` framing. `call.completed` closes the stream; `call.aborted`
|
||||
closes the stream with an error frame. This is the native streaming
|
||||
projection; SSE (ADR-036) is the projection for `h2`/`http/1.1` clients
|
||||
that don't speak WebTransport.
|
||||
|
||||
### The TLS constraint (browsers require X.509)
|
||||
|
||||
Browsers do not support RFC 7250 raw public keys (ADR-027, OQ-12). A
|
||||
WebTransport session from a browser requires an X.509 cert — the `h3`
|
||||
handler is a domain-hosted-service concern, not a P2P concern. A node
|
||||
serving WebTransport must have an X.509 identity
|
||||
(`TlsIdentity::X509` or `TlsIdentity::Acme`).
|
||||
|
||||
This is a property of the browser, not a decision this spec makes. It's
|
||||
recorded so the spec doesn't pretend a raw-key node can serve browsers.
|
||||
A raw-key node serves `h2`/`http/1.1` (for curl, axios, alknet-native
|
||||
clients) but not `h3`/WebTransport (for browsers). A browser-facing hub
|
||||
has a `PeerEntry` with mixed fingerprints (Ed25519 for P2P, X.509 for
|
||||
browsers — ADR-030, ADR-034 §3).
|
||||
|
||||
### Browsers are not alknet peers
|
||||
|
||||
A browser connecting to a hub over WebTransport is served by the `h3`
|
||||
handler. The browser authenticates by bearer token (HTTP `Authorization`
|
||||
header on the WebTransport session request), resolved by the hub's
|
||||
`IdentityProvider::resolve_from_token` against the hub's
|
||||
`PeerEntry.auth_token_hash` or `ApiKeyEntry`. The browser is **not** an
|
||||
alknet peer (ADR-034 §4): it gets no `PeerId`, does not enter
|
||||
`PeerCompositeEnv`, and its "ops" are WebTransport streams served by
|
||||
the `h3` handler, not entries in the call-protocol peer-keyed overlay.
|
||||
|
||||
### Stealth on h3
|
||||
|
||||
The `h3` handler participates in the same stealth model as `h2`/
|
||||
`http/1.1` (ADR-010, ADR-036): a client that offers `h3` gets the HTTP
|
||||
handler. Unknown WebTransport paths and unknown HTTP/3 paths get the
|
||||
decoy (the same configurable `DecoyConfig` — fake 404, static site,
|
||||
redirect). Real services use `alknet/ssh`, `alknet/call`, etc.
|
||||
|
||||
### Implementation reference: wtransport
|
||||
|
||||
The `wtransport` crate (`/workspace/wtransport/`, v0.7.1) is a pure-Rust
|
||||
WebTransport implementation built on `quinn` + `h3`/`qpack`. Its API:
|
||||
|
||||
```rust
|
||||
// Server (from the wtransport README):
|
||||
let config = ServerConfig::builder()
|
||||
.with_bind_default(4433)
|
||||
.with_identity(&identity) // X.509 identity
|
||||
.build();
|
||||
let connection = Endpoint::server(config)?
|
||||
.accept().await // await connection
|
||||
.await? // await session request
|
||||
.accept().await?; // await ready session
|
||||
let stream = connection.accept_bi().await?;
|
||||
```
|
||||
|
||||
`wtransport` is a candidate dependency for the `h3` feature gate. The
|
||||
exact WebTransport library choice (wtransport vs a quinn-native HTTP/3
|
||||
+ WebTransport extension) is a two-way-door implementation detail
|
||||
(ADR-038); the one-way constraint is that `h3` is served by this crate
|
||||
as a first-class transport.
|
||||
|
||||
## Constraints
|
||||
|
||||
- **`h3` requires X.509.** Browsers don't support RFC 7250 (ADR-027).
|
||||
A node serving `h3` must have an X.509 identity. Raw-key-only nodes
|
||||
serve `h2`/`http/1.1` but not `h3`.
|
||||
- **`h3` is behind the `h3` feature gate.** The `wtransport` (or
|
||||
quinn HTTP/3 extension) dependency is heavier than `h2`/`http/1.1`;
|
||||
non-browser-facing deployments don't compile it.
|
||||
- **Browsers are not alknet peers.** A browser over WebTransport
|
||||
authenticates by bearer token, gets no `PeerId` (ADR-034 §4).
|
||||
- **WebTransport streams target the call protocol directly.** A
|
||||
WebTransport bidirectional stream carrying an `EventEnvelope` is
|
||||
handed to the call protocol's `Dispatcher` — no SSE translation, no
|
||||
HTTP framing. The browser speaks the call protocol wire format
|
||||
directly.
|
||||
- **The HTTP/3 request path uses the same axum `Router` as `h2`/
|
||||
`http/1.1`.** An HTTP/3 request is just another HTTP request from
|
||||
the router's perspective (ADR-036).
|
||||
- **WebTransport is a draft standard.** The `wtransport` README notes
|
||||
the protocol is not yet standardized; the API may change. The `h3`
|
||||
feature gate isolates the risk.
|
||||
|
||||
## Design Decisions
|
||||
|
||||
| Decision | ADR | Summary |
|
||||
|----------|-----|---------|
|
||||
| `h3`/WebTransport is first-class | [ADR-038](../../decisions/038-http3-and-webtransport-as-first-class.md) | In scope, not deferred; browser streaming uses QUIC streams |
|
||||
| Browsers require X.509 | [ADR-027](../../decisions/027-tls-identity-redesign-acme-rawkey-decoupling.md) | `h3` needs X.509 (browser limitation) |
|
||||
| Browsers are not alknet peers | [ADR-034](../../decisions/034-outgoing-only-x509-and-three-peer-roles.md) | Bearer token, no `PeerId` |
|
||||
| WebTransport streams → call protocol directly | [ADR-012](../../decisions/012-call-protocol-stream-model.md) | Stream-agnostic; WebTransport stream = QUIC bidirectional stream |
|
||||
| Stealth on h3 | [ADR-010](../../decisions/010-alpn-router-and-endpoint.md) | Unknown paths get the decoy |
|
||||
| HTTP path = operation path (for HTTP/3 requests) | [ADR-036](../../decisions/036-http-to-call-operation-mapping.md) | Same axum `Router` as h2/http1.1 |
|
||||
|
||||
## Open Questions
|
||||
|
||||
See [open-questions.md](../../open-questions.md) for full details.
|
||||
|
||||
- **OQ-38** (open, scope): WebTransport relay-as-proxy — a proxy that
|
||||
terminates the browser's WebTransport connection and forwards to a
|
||||
P2P hub's Ed25519 endpoint (so the hub need not expose a public
|
||||
X.509 cert). Recorded in ADR-034 §5. Does the proxy live in
|
||||
`alknet-http` or a separate relay crate? This is a genuine scope
|
||||
question (the proxy use case is not yet concrete enough to decide the
|
||||
crate boundary), not a deferral.
|
||||
|
||||
## References
|
||||
|
||||
- [ADR-038](../../decisions/038-http3-and-webtransport-as-first-class.md)
|
||||
— the decision that `h3` is in scope
|
||||
- [ADR-036](../../decisions/036-http-to-call-operation-mapping.md) —
|
||||
the HTTP-to-call mapping (the HTTP/3 request path uses the same
|
||||
axum `Router`)
|
||||
- [overview.md](overview.md) — crate overview, feature gates
|
||||
- [http-server.md](http-server.md) — the `h2`/`http/1.1` companion
|
||||
- `/workspace/wtransport/` — pure-Rust WebTransport reference
|
||||
implementation (the `h3` feature's candidate dependency)
|
||||
Reference in New Issue
Block a user