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:
226
tasks/http/adapters/from-mcp.md
Normal file
226
tasks/http/adapters/from-mcp.md
Normal file
@@ -0,0 +1,226 @@
|
||||
---
|
||||
id: http/adapters/from-mcp
|
||||
name: Implement from_mcp adapter (rmcp streamable HTTP client, tools/list discovery, structuredContent handling)
|
||||
status: pending
|
||||
depends_on: [http/client/shared-http-client, http/gateway/error-mapping]
|
||||
scope: broad
|
||||
risk: medium
|
||||
impact: component
|
||||
level: implementation
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
Implement `from_mcp` in `src/adapters/from_mcp.rs` (feature-gated behind
|
||||
`mcp`). This is the MCP-direction adapter: it 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). Implements
|
||||
`OperationAdapter` (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.
|
||||
|
||||
### The adapter (http-mcp.md §"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` = depends on whether the tool declares
|
||||
`outputSchema` (MCP 2025-06-18+):
|
||||
- **`outputSchema` present** → `output_schema` = the declared
|
||||
schema (converted from JSON Schema). The result arrives in
|
||||
`CallToolResult.structured_content` and is composable with
|
||||
local operations.
|
||||
- **`outputSchema` absent** (older MCP servers) → `output_schema`
|
||||
= the MCP `ContentBlock` union (`text | image | audio | resource
|
||||
| resource_link` — a well-defined MCP type, *not*
|
||||
`Type.Unknown()`). The result arrives in
|
||||
`CallToolResult.content` as a `Vec<ContentBlock>`. See "Output
|
||||
handling" below.
|
||||
- `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 (http-mcp.md §"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 the result from the `CallToolResult`, following
|
||||
the `structuredContent`-preferred-over-content-blocks rule (see
|
||||
"Output handling" below), 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.
|
||||
|
||||
### Output handling: structuredContent vs content blocks (http-mcp.md §"Output handling")
|
||||
|
||||
MCP `CallToolResult` (rmcp `model.rs`) carries two result fields:
|
||||
`content: Vec<ContentBlock>` (always present, defaults to `[]`) and
|
||||
`structured_content: Option<Value>` (present when the tool declared
|
||||
`outputSchema`). The `from_mcp` handler follows the same rule the TS
|
||||
adapter (`@alkdev/operations/src/from_mcp.ts`) and the rmcp SDK
|
||||
(`CallToolResult::into_typed`) use:
|
||||
|
||||
- **`structured_content` present** (tool declared `outputSchema`): the
|
||||
handler uses `structured_content` as the result, validated/cast
|
||||
against the declared `output_schema`. This is the composable case —
|
||||
the data matches the declared type, so a composing handler can use it
|
||||
as a typed value.
|
||||
- **`structured_content` absent** (older server, no `outputSchema`):
|
||||
the handler maps `content: Vec<ContentBlock>` to the
|
||||
`ContentBlock`-union `output_schema` (text/image/audio/resource/
|
||||
resource_link). The TS `mapMCPContentBlocks` shows the mapping; the
|
||||
Rust `ContentBlock` enum (`rmcp/src/model/content.rs`) is the same
|
||||
shape. The common sub-case is a single `Text` block — older servers
|
||||
often JSON-stringify structured data into the `text` field. The
|
||||
adapter does *not* attempt to `JSON.parse` the text heuristically
|
||||
(fragile, not the adapter's concern); it carries the `ContentBlock`
|
||||
union as the typed result. A consumer that knows the text is JSON can
|
||||
parse it downstream.
|
||||
|
||||
The `isError: true` case is handled separately (step 4 above) — it
|
||||
maps to a `CallError`, not to the output handling path.
|
||||
|
||||
### No-Env-Vars (http-mcp.md §"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).
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] `FromMCP` struct with `endpoint`, `auth_token`, `namespace`
|
||||
- [ ] `OperationAdapter` impl: `async fn import(&self) -> Result<Vec<HandlerRegistration>, AdapterError>`
|
||||
- [ ] Connects via rmcp `StreamableHttpClientTransport::from_uri(endpoint)`
|
||||
- [ ] Connection failure → `AdapterError::DiscoveryFailed`
|
||||
- [ ] 401 → `AdapterError::Unauthorized`
|
||||
- [ ] Calls `tools/list` → MCP tools (name, description, inputSchema, outputSchema)
|
||||
- [ ] For each tool: constructs `HandlerRegistration`
|
||||
- [ ] `spec.name` = tool name (or `namespace/tool_name` with prefix)
|
||||
- [ ] `spec.namespace` = configured `namespace`
|
||||
- [ ] `spec.op_type` = `Mutation` (MCP tools are call/response)
|
||||
- [ ] `spec.visibility` = `Internal` (ADR-015)
|
||||
- [ ] `spec.input_schema` = tool's `inputSchema`
|
||||
- [ ] `spec.output_schema` = declared `outputSchema` if present, else `ContentBlock` union
|
||||
- [ ] `spec.error_schemas` from MCP tool error descriptions (ADR-023)
|
||||
- [ ] `spec.access_control` = `AccessControl::default()`
|
||||
- [ ] `provenance` = `FromMCP`, `composition_authority: None`, `scoped_env: None` (ADR-022)
|
||||
- [ ] `capabilities` = bearer token for MCP server (injected at registration)
|
||||
- [ ] Forwarding handler calls `client.call_tool({ name, arguments })`
|
||||
- [ ] `structured_content` present → use as result (validated against `output_schema`)
|
||||
- [ ] `structured_content` absent → map `content: Vec<ContentBlock>` to `ContentBlock` union
|
||||
- [ ] No heuristic `JSON.parse` of text blocks (carry as `ContentBlock`)
|
||||
- [ ] `isError: true` → `CallError` with MCP error content
|
||||
- [ ] rmcp client connection maintained for registration lifetime
|
||||
- [ ] No-env-vars: handler reads `context.capabilities`, never `std::env::var` (ADR-014)
|
||||
- [ ] Feature-gated behind `mcp` (no compile without `mcp` feature)
|
||||
- [ ] stdio transport NOT built (ADR-037 — streamable HTTP only)
|
||||
- [ ] Unit test: `import()` with mock MCP server → `HandlerRegistration` bundles
|
||||
- [ ] Unit test: `outputSchema` present → `output_schema` = declared schema
|
||||
- [ ] Unit test: `outputSchema` absent → `output_schema` = `ContentBlock` union
|
||||
- [ ] Unit test: `structured_content` present → used as result
|
||||
- [ ] Unit test: `structured_content` absent → `content` blocks mapped to union
|
||||
- [ ] Unit test: `isError: true` → `CallError`
|
||||
- [ ] Integration test: forwarding handler calls remote MCP tool via rmcp
|
||||
- [ ] Integration test: no `std::env::var` reads in the forwarding handler
|
||||
- [ ] `cargo test -p alknet-http --features mcp` succeeds
|
||||
- [ ] `cargo clippy -p alknet-http --features mcp --all-targets` succeeds with no warnings
|
||||
- [ ] `cargo check -p alknet-http` (no `mcp` feature) succeeds — from_mcp not compiled
|
||||
|
||||
## References
|
||||
|
||||
- docs/architecture/crates/http/http-mcp.md — from_mcp (full spec)
|
||||
- docs/architecture/decisions/037-mcp-stdio-transport-exclusion.md — ADR-037 (streamable HTTP only)
|
||||
- docs/architecture/decisions/017-call-protocol-client-and-adapter-contract.md — ADR-017 §5 (OperationAdapter)
|
||||
- docs/architecture/decisions/015-privilege-model-and-authority-context.md — ADR-015 (Internal)
|
||||
- docs/architecture/decisions/022-handler-registration-provenance-and-composition-authority.md — ADR-022 (leaf)
|
||||
- docs/architecture/decisions/023-operation-error-schemas.md — ADR-023 (error fidelity)
|
||||
- docs/architecture/decisions/014-secret-material-flow-and-capability-injection.md — ADR-014 (no env vars)
|
||||
- /workspace/rust-sdk/crates/rmcp/src/model.rs — CallToolResult (content + structured_content)
|
||||
- /workspace/rust-sdk/crates/rmcp/src/model/content.rs — ContentBlock enum
|
||||
- /workspace/rust-sdk/examples/clients/src/streamable_http.rs — streamable HTTP MCP client pattern
|
||||
- /workspace/@alkdev/operations/src/from_mcp.ts — TypeScript prior art (mapMCPContentBlocks, structuredContent logic)
|
||||
|
||||
## Notes
|
||||
|
||||
> from_mcp is feature-gated behind mcp (rmcp dependency). Streamable HTTP
|
||||
> only — stdio is NOT built (ADR-037). The output handling follows the
|
||||
> structuredContent-preferred-over-content-blocks rule (same as the TS
|
||||
> adapter and rmcp's into_typed). The adapter does NOT heuristically
|
||||
> JSON.parse text blocks — it carries the ContentBlock union as the
|
||||
> typed result; a downstream consumer that knows the text is JSON can
|
||||
> parse it. The no-env-vars invariant applies (handler reads
|
||||
> context.capabilities, not std::env::var). The rmcp client connection
|
||||
> is maintained for the registration lifetime (persistent streamable HTTP
|
||||
> endpoint, not per-call). The handler is opaque to CallAdapter
|
||||
> (Arc<dyn Handler>); alknet-call never sees rmcp.
|
||||
|
||||
## Summary
|
||||
|
||||
> To be filled on completion
|
||||
242
tasks/http/adapters/from-openapi.md
Normal file
242
tasks/http/adapters/from-openapi.md
Normal file
@@ -0,0 +1,242 @@
|
||||
---
|
||||
id: http/adapters/from-openapi
|
||||
name: Implement from_openapi adapter (parse OpenAPI, reqwest forwarding handlers, no-env-vars injection)
|
||||
status: pending
|
||||
depends_on: [http/client/shared-http-client, http/gateway/error-mapping]
|
||||
scope: broad
|
||||
risk: medium
|
||||
impact: component
|
||||
level: implementation
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
Implement `from_openapi` in `src/adapters/from_openapi.rs`. This is the
|
||||
OpenAPI-direction adapter: it 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).
|
||||
|
||||
### The adapter (http-adapters.md §"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 (http-adapters.md §"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 Rust type, but not the
|
||||
/// same document shape. Coordinate with the to-openapi task on the 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 scheme (from Capabilities at registration, not env vars),
|
||||
/// and optional headers. The auth field is the scheme the external API
|
||||
/// expects; 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 import flow (http-adapters.md §"from_openapi")
|
||||
|
||||
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 (http-adapters.md §"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 HTTP client (`SharedHttpClient` —
|
||||
the `shared-http-client` task).
|
||||
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`.
|
||||
|
||||
### No-Env-Vars credential injection (http-adapters.md §"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`.
|
||||
|
||||
### Error Fidelity (ADR-023, http-adapters.md §"Error Fidelity")
|
||||
|
||||
`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 }
|
||||
```
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] `FromOpenAPI` struct with `spec: OpenAPISpec`, `config: HttpServiceConfig`
|
||||
- [ ] `OperationAdapter` impl: `async fn import(&self) -> Result<Vec<HandlerRegistration>, AdapterError>`
|
||||
- [ ] Parses OpenAPI doc (`paths`, `components`, `$ref` resolution)
|
||||
- [ ] Parse failure → `AdapterError::SchemaParse`
|
||||
- [ ] For each `(path, method, operation)`: constructs `HandlerRegistration`
|
||||
- [ ] `spec.name` = `operationId` (or generated `${method}_${path_parts}`)
|
||||
- [ ] `spec.namespace` = `config.namespace`
|
||||
- [ ] `spec.op_type` = `Query`/`Mutation`/`Subscription` (detected from method + response content type)
|
||||
- [ ] `spec.visibility` = `Internal` (ADR-015)
|
||||
- [ ] `spec.input_schema` / `output_schema` from OpenAPI parameters/responses
|
||||
- [ ] `spec.error_schemas` from non-2xx OpenAPI responses with `HTTP_<status>` prefix (ADR-023)
|
||||
- [ ] `spec.access_control` = `AccessControl::default()`
|
||||
- [ ] `provenance` = `FromOpenAPI`, `composition_authority: None`, `scoped_env: None` (ADR-022)
|
||||
- [ ] `capabilities` = credentials for the external endpoint (injected at registration)
|
||||
- [ ] Forwarding handler builds outbound HTTP request (path params, query, headers, body)
|
||||
- [ ] Forwarding handler sends via `SharedHttpClient` (the shared client)
|
||||
- [ ] `Query`/`Mutation`: parses response body (JSON/text/binary), wraps in `ResponseEnvelope`
|
||||
- [ ] `Subscription` (`text/event-stream`): streams `call.responded` from SSE chunks, then `call.completed`
|
||||
- [ ] HTTP error (non-2xx): maps to declared `ErrorDefinition` by status code, returns `CallError`
|
||||
- [ ] No-env-vars: handler reads `context.capabilities.get("<service>")`, never `std::env::var` (ADR-014)
|
||||
- [ ] Bearer/ApiKey/Basic auth injection from `Capabilities`
|
||||
- [ ] `HttpAuthScheme` enum with `Bearer`, `ApiKey { header_name }`, `Basic`
|
||||
- [ ] `HttpServiceConfig` with `namespace`, `base_url`, `auth`, `default_headers`
|
||||
- [ ] Unit test: parse a minimal OpenAPI doc → one `HandlerRegistration`
|
||||
- [ ] Unit test: parse failure → `AdapterError::SchemaParse`
|
||||
- [ ] Unit test: `operationId` absent → generated name `${method}_${path_parts}`
|
||||
- [ ] Unit test: GET → `Query`, POST → `Mutation`, SSE response → `Subscription`
|
||||
- [ ] Unit test: error response 404 → `ErrorDefinition { code: "HTTP_404", http_status: Some(404) }`
|
||||
- [ ] Unit test: forwarding handler injects Bearer token from `context.capabilities`
|
||||
- [ ] Integration test: forwarding handler calls external endpoint via `SharedHttpClient`
|
||||
- [ ] Integration test: SSE response streams `call.responded` events
|
||||
- [ ] Integration test: no `std::env::var` reads in the forwarding handler
|
||||
- [ ] `cargo test -p alknet-http` succeeds
|
||||
- [ ] `cargo clippy -p alknet-http --all-targets` succeeds with no warnings
|
||||
|
||||
## References
|
||||
|
||||
- docs/architecture/crates/http/http-adapters.md — from_openapi (full spec)
|
||||
- docs/architecture/decisions/017-call-protocol-client-and-adapter-contract.md — ADR-017 §5 (OperationAdapter trait)
|
||||
- docs/architecture/decisions/015-privilege-model-and-authority-context.md — ADR-015 (Internal visibility)
|
||||
- docs/architecture/decisions/022-handler-registration-provenance-and-composition-authority.md — ADR-022 (leaf provenance)
|
||||
- docs/architecture/decisions/023-operation-error-schemas.md — ADR-023 (HTTP_<status> prefix, error fidelity)
|
||||
- docs/architecture/decisions/014-secret-material-flow-and-capability-injection.md — ADR-014 (no env vars)
|
||||
- /workspace/@alkdev/operations/src/from_openapi.ts — TypeScript prior art (parsing, SSE, auth headers, createHTTPOperation, parseSSEFrames)
|
||||
|
||||
## Notes
|
||||
|
||||
> from_openapi is the no-env-vars credential injection point. The
|
||||
> forwarding handler reads context.capabilities, not std::env::var —
|
||||
> this is the spec-level invariant (ADR-014). The handler is opaque to
|
||||
> CallAdapter (Arc<dyn Handler>); alknet-call never sees reqwest. The
|
||||
> error codes are prefixed HTTP_<status> to avoid collision with
|
||||
> protocol-level codes (ADR-023, review #002 W20). The OpenAPISpec type
|
||||
> is shared with to_openapi (coordinate on the type); the shape is not
|
||||
> (from_openapi consumes per-operation-paths, to_openapi produces the
|
||||
> 5-endpoint gateway doc). The TS prior art (@alkdev/operations/src/
|
||||
> from_openapi.ts) shows the parsing patterns (resolveRef,
|
||||
> buildInputSchema, buildOutputSchema, detectOperationType,
|
||||
> createHTTPOperation, parseSSEFrames) — the SSE normalization patterns
|
||||
> stay referenced, the client construction anti-patterns (env-var
|
||||
> config, hand-rolled retry) are discarded.
|
||||
|
||||
## Summary
|
||||
|
||||
> To be filled on completion
|
||||
208
tasks/http/adapters/to-mcp.md
Normal file
208
tasks/http/adapters/to-mcp.md
Normal file
@@ -0,0 +1,208 @@
|
||||
---
|
||||
id: http/adapters/to-mcp
|
||||
name: Implement to_mcp gateway projection (4-tool gateway, rmcp StreamableHttpService, ADR-041)
|
||||
status: pending
|
||||
depends_on: [http/gateway/gateway-dispatch-spine, http/server/bearer-auth-middleware]
|
||||
scope: broad
|
||||
risk: medium
|
||||
impact: component
|
||||
level: implementation
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
Implement `to_mcp` in `src/adapters/to_mcp.rs` (feature-gated behind
|
||||
`mcp`). This is the MCP-direction gateway projection: it exposes the
|
||||
local registry's `External` operations as a **fixed gateway tool set**
|
||||
over streamable HTTP — not one MCP tool per operation. This is the
|
||||
tool-gateway pattern (ADR-041): the LLM has a few tools in context
|
||||
(search, schema, call, batch), not hundreds, and discovers operations
|
||||
on demand through the gateway.
|
||||
|
||||
### Pure projection (ADR-017 §5)
|
||||
|
||||
`to_mcp` is a pure projection — it consumes the registry and does not
|
||||
produce entries for it. It is not an `OperationAdapter`. An external MCP
|
||||
client (an editor, an AI tool) discovers and calls alknet operations
|
||||
through the MCP protocol.
|
||||
|
||||
### The gateway tool set (http-mcp.md §"The gateway tool set")
|
||||
|
||||
`to_mcp` exposes four MCP tools that gate access to the full operation
|
||||
registry:
|
||||
|
||||
| MCP tool | Call protocol operation | Purpose |
|
||||
|----------|------------------------|---------|
|
||||
| `search` | `services/list` | List/search available operations (filtered by the caller's `AccessControl`). Returns names + descriptions, not full schemas. |
|
||||
| `schema` | `services/schema` | Get an operation's full `OperationSpec` (input/output JSON Schemas, error schemas). |
|
||||
| `call` | `call.requested` (Query/Mutation) | Invoke an operation by name with a JSON input. Returns the output or a typed error (ADR-023). |
|
||||
| `batch` | multiple `call.requested` | Invoke multiple operations in one tool call (correlated request IDs, OQ-14). |
|
||||
|
||||
The LLM calls `search` to discover operations, `schema` to learn an
|
||||
operation's input shape, `call` to invoke. Same pattern as
|
||||
`man <command>` — discover on demand, don't preload. See ADR-041 for
|
||||
the rationale (the tool-bloat problem).
|
||||
|
||||
### `Subscription` exclusion (http-mcp.md §"Subscription exclusion")
|
||||
|
||||
The gateway exposes only `Query` and `Mutation` operations
|
||||
(request/response). `Subscription` operations (streaming) are filtered
|
||||
out of `search` results and cannot be invoked via `call` — MCP tool
|
||||
calls are request/response by protocol design; streaming subscriptions
|
||||
don't fit the LLM tool-call pattern. See ADR-041 §2.
|
||||
|
||||
### `to_mcp` service behavior (http-mcp.md §"to_mcp service behavior")
|
||||
|
||||
1. On MCP `tools/list`: returns the fixed gateway tool set (4 tools:
|
||||
`search`, `schema`, `call`, `batch`), not the registry's
|
||||
operations. The gateway tools have stable names and schemas; the
|
||||
registry's operations are discovered through `search`.
|
||||
|
||||
2. On MCP `tools/call`:
|
||||
- `search` → dispatches `services/list` (filtered by the caller's
|
||||
`AccessControl`), returns operation names + descriptions.
|
||||
- `schema` → dispatches `services/schema`, returns the
|
||||
`OperationSpec`.
|
||||
- `call` → dispatches `OperationRegistry::invoke()` (via the shared
|
||||
`GatewayDispatch::invoke()` — the dispatch spine). The result is
|
||||
mapped to an MCP `CallToolResult` (`structuredContent` for the
|
||||
output, or `isError: true` for a `CallError` with typed
|
||||
`details` per ADR-023).
|
||||
- `batch` → dispatches multiple `call.requested` events, returns
|
||||
an array of results.
|
||||
|
||||
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).
|
||||
`AccessControl` gates `search` results and `call` dispatch — the
|
||||
LLM sees only what it's authorized to call.
|
||||
|
||||
### rmcp integration (http-mcp.md §"to_mcp", research §4)
|
||||
|
||||
The rmcp `simple_auth_streamhttp.rs` server example shows the
|
||||
streamable-HTTP-service-into-axum-`Router` 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 axum integration pattern, but
|
||||
the rmcp `Service` impl is a gateway service (4 fixed tools) rather than
|
||||
a per-operation tool registry. The `to_mcp` gateway implements rmcp's
|
||||
`ServerHandler` trait (`call_tool` / `list_tools`) and is wrapped by
|
||||
`StreamableHttpService` (a `tower::Service<Request<RequestBody>>`).
|
||||
|
||||
### Shared dispatch spine with `to_openapi` (http-mcp.md §"Shared dispatch spine")
|
||||
|
||||
`to_mcp`'s `call` tool and `to_openapi`'s `/call` endpoint share the
|
||||
same dispatch spine: resolve caller identity (Bearer →
|
||||
`IdentityProvider::resolve_from_token`) → build a root
|
||||
`OperationContext` → `OperationRegistry::invoke()` → map the
|
||||
`ResponseEnvelope` to the gateway's wire shape (`CallToolResult` for
|
||||
MCP, HTTP JSON for OpenAPI). The wire framing, discovery listing
|
||||
(`tools/list` vs `/search`), streaming (excluded vs `/subscribe` SSE),
|
||||
and server integration (rmcp `StreamableHttpService` tower service vs
|
||||
axum route handlers) are genuinely per-gateway and are not shared.
|
||||
|
||||
The shared spine is the `GatewayDispatch` struct (the
|
||||
`gateway-dispatch-spine` task). `to_mcp` holds an `Arc<GatewayDispatch>`
|
||||
(or it lives in the rmcp service state) and calls
|
||||
`GatewayDispatch::invoke()` for the `call` tool. The
|
||||
`ResponseEnvelope` → `CallToolResult` mapping is `to_mcp`-specific.
|
||||
|
||||
### Auth: shared middleware (research §4.4)
|
||||
|
||||
The Bearer auth middleware (the `bearer-auth-middleware` task) is applied
|
||||
as an axum layer *around* the nested `StreamableHttpService` (the rmcp
|
||||
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`).
|
||||
|
||||
### `CallToolResult` mapping
|
||||
|
||||
The `ResponseEnvelope` → `CallToolResult` mapping uses rmcp's
|
||||
`IntoCallToolResult` trait (`tool.rs:78-113`):
|
||||
|
||||
- `Ok(value)` → `CallToolResult::structured(value)` (`model.rs:3006`).
|
||||
- `Err(call_error)` → `CallToolResult::structured_error(error.details)`
|
||||
(`model.rs:3032`) or `CallToolResult::error(error_data)`.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] `to_mcp` implements rmcp `ServerHandler` trait (`call_tool`, `list_tools`)
|
||||
- [ ] `tools/list` returns 4 fixed gateway tools (`search`, `schema`, `call`, `batch`)
|
||||
- [ ] `tools/list` does NOT return the registry's operations (discovered via `search`)
|
||||
- [ ] `search` tool → dispatches `services/list` via `GatewayDispatch::invoke`
|
||||
- [ ] `search` results are `AccessControl::check(identity)`-filtered
|
||||
- [ ] `search` results are names + descriptions (not full schemas)
|
||||
- [ ] `Subscription` ops filtered out of `search` results (ADR-041 §2)
|
||||
- [ ] `schema` tool → dispatches `services/schema` via `GatewayDispatch::invoke`
|
||||
- [ ] `call` tool → dispatches via `GatewayDispatch::invoke` (shared spine)
|
||||
- [ ] `call` result → `CallToolResult::structured(value)` for `Ok`
|
||||
- [ ] `call` error → `CallToolResult::structured_error(details)` for `Err(CallError)`
|
||||
- [ ] `batch` tool → loop over `GatewayDispatch::invoke`, returns array
|
||||
- [ ] Bearer auth via shared `bearer_auth_middleware` (applied around `nest_service`)
|
||||
- [ ] `Identity` read from `RequestContext.extensions` inside `call_tool`
|
||||
- [ ] MCP client has no `PeerId` (not an alknet peer, ADR-034 §4)
|
||||
- [ ] `AccessControl` gates `search` results and `call` dispatch
|
||||
- [ ] `to_mcp` is a pure projection (consumes registry, does not produce entries)
|
||||
- [ ] `StreamableHttpService` nested into axum `Router` at `/mcp`
|
||||
- [ ] Feature-gated behind `mcp` (no compile without `mcp` feature)
|
||||
- [ ] stdio transport NOT built (ADR-037)
|
||||
- [ ] Unit test: `tools/list` returns exactly 4 gateway tools
|
||||
- [ ] Unit test: `search` returns AccessControl-filtered ops (no Subscriptions)
|
||||
- [ ] Unit test: `schema` returns full OperationSpec
|
||||
- [ ] Unit test: `call` → `CallToolResult::structured` for success
|
||||
- [ ] Unit test: `call` → `CallToolResult::structured_error` for CallError
|
||||
- [ ] Unit test: `batch` returns array of results
|
||||
- [ ] Integration test: MCP client calls `search` → `schema` → `call` round-trip
|
||||
- [ ] Integration test: Bearer auth middleware gates `to_mcp` service
|
||||
- [ ] Integration test: `Identity` survives rmcp framing (research §6 #2)
|
||||
- [ ] `cargo test -p alknet-http --features mcp` succeeds
|
||||
- [ ] `cargo clippy -p alknet-http --features mcp --all-targets` succeeds with no warnings
|
||||
- [ ] `cargo check -p alknet-http` (no `mcp` feature) succeeds — to_mcp not compiled
|
||||
|
||||
## References
|
||||
|
||||
- docs/architecture/crates/http/http-mcp.md — to_mcp (full spec)
|
||||
- docs/research/alknet-http-gateway-factoring/findings.md — §4 (rmcp StreamableHttpService constraints), §4.4 (auth middleware sharing)
|
||||
- docs/architecture/decisions/041-mcp-tool-gateway-pattern.md — ADR-041 (4-tool gateway, Subscription exclusion)
|
||||
- docs/architecture/decisions/037-mcp-stdio-transport-exclusion.md — ADR-037 (streamable HTTP only)
|
||||
- docs/architecture/decisions/017-call-protocol-client-and-adapter-contract.md — ADR-017 §5 (to_* are projections)
|
||||
- docs/architecture/decisions/023-operation-error-schemas.md — ADR-023 (error fidelity, CallToolResult mapping)
|
||||
- docs/architecture/decisions/034-outgoing-only-x509-and-three-peer-roles.md — ADR-034 §4 (MCP clients are not peers)
|
||||
- /workspace/rust-sdk/examples/servers/src/simple_auth_streamhttp.rs — axum middleware around nested StreamableHttpService
|
||||
- /workspace/rust-sdk/crates/rmcp/src/handler/server.rs — ServerHandler trait (call_tool, list_tools)
|
||||
- /workspace/rust-sdk/crates/rmcp/src/handler/server/tool.rs — IntoCallToolResult trait
|
||||
- /workspace/rust-sdk/crates/rmcp/src/model.rs — CallToolResult (structured, structured_error)
|
||||
|
||||
## Notes
|
||||
|
||||
> to_mcp is the 4-tool gateway (ADR-041): search/schema/call/batch, not
|
||||
> one tool per operation. The LLM discovers operations on demand through
|
||||
> search + schema, same as man <command>. Subscription ops are excluded
|
||||
> (MCP tool calls are request/response). The shared dispatch spine
|
||||
> (GatewayDispatch) is used for the call tool; the ResponseEnvelope →
|
||||
> CallToolResult mapping is to_mcp-specific. The Bearer auth middleware
|
||||
> is shared with the HTTP routes (research §4.4 — applied around
|
||||
> nest_service). The load-bearing assumption is that Identity survives
|
||||
> the rmcp framing (research §6 #2 — confirm with a spike that
|
||||
> ctx.extensions.get::<Identity>() works inside call_tool). to_mcp is a
|
||||
> pure projection (consumes registry, does not produce entries). The mcp
|
||||
> feature gate is optional; stdio is NOT built (ADR-037).
|
||||
|
||||
## Summary
|
||||
|
||||
> To be filled on completion
|
||||
188
tasks/http/adapters/to-openapi.md
Normal file
188
tasks/http/adapters/to-openapi.md
Normal file
@@ -0,0 +1,188 @@
|
||||
---
|
||||
id: http/adapters/to-openapi
|
||||
name: Implement to_openapi gateway projection (5-endpoint OpenAPI doc, info.version semver, ADR-042/045)
|
||||
status: pending
|
||||
depends_on: [http/server/gateway-endpoints, http/gateway/gateway-dispatch-spine]
|
||||
scope: moderate
|
||||
risk: medium
|
||||
impact: component
|
||||
level: implementation
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
Implement `to_openapi` in `src/adapters/to_openapi.rs`. This is the
|
||||
OpenAPI gateway projection: it generates an OpenAPI document with a
|
||||
**fixed 5-endpoint gateway set** that gates access to the full operation
|
||||
registry — not one path per operation (ADR-042). The external client (a
|
||||
code generator, a human developer, a `fetch`-based client) calls
|
||||
`/search` to discover operations, `/schema` to learn an operation's
|
||||
input shape, `/call` to invoke. Served at `GET /openapi.json` by the HTTP
|
||||
server.
|
||||
|
||||
### Pure projection (ADR-017 §5)
|
||||
|
||||
`to_openapi` 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`.
|
||||
|
||||
```rust
|
||||
/// Generate an OpenAPI document describing the 5 gateway endpoints.
|
||||
/// Pure projection: consumes the registry, does not produce entries.
|
||||
/// The per-caller operation surface is discovered via /search, not
|
||||
/// preloaded into the doc (ADR-042 §3).
|
||||
pub fn to_openapi(registry: &OperationRegistry) -> OpenAPISpec;
|
||||
```
|
||||
|
||||
### The gateway endpoint set (http-adapters.md §"The gateway endpoint set")
|
||||
|
||||
`to_openapi` generates 5 fixed endpoints:
|
||||
|
||||
| OpenAPI path | 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`. |
|
||||
|
||||
The input is always a flat JSON body — no path/query/body split to
|
||||
reverse-engineer. JSON Schema for the input/output is already in the
|
||||
`OperationSpec`; the gateway wraps it in OpenAPI's schema format without
|
||||
splitting parameters.
|
||||
|
||||
`/subscribe` is the one endpoint the MCP gateway excludes (ADR-041 —
|
||||
MCP tool calls are request/response). OpenAPI/SSE supports streaming;
|
||||
the gateway's `/subscribe` uses the SSE projection — `call.responded` →
|
||||
SSE `data:` frames, `call.completed` → stream close.
|
||||
|
||||
### Per-caller API surface (http-adapters.md §"Per-caller API surface")
|
||||
|
||||
The `/search` endpoint's results are `AccessControl::check(identity)`-
|
||||
filtered — the client sees only the operations it is authorized to call.
|
||||
The generated OpenAPI doc describes the 5 gateway endpoints (stable,
|
||||
same for every caller); the per-caller operation surface is discovered
|
||||
through `/search`, not preloaded into the doc. This is the key
|
||||
advantage over a traditional per-operation-paths OpenAPI doc: the
|
||||
per-caller API surface is the default (the Gitea failure mode — dumping
|
||||
admin ops to every caller — is structurally impossible).
|
||||
|
||||
### `info.version` semver (ADR-045, OQ-39 resolved)
|
||||
|
||||
The generated gateway doc carries `info.version` (semver) tracking the
|
||||
**gateway endpoint contract**, not the operation set — per-caller
|
||||
operation changes (add/remove/modify, schema changes) do not bump the
|
||||
version (the operation set is discovered via `/search`, not preloaded
|
||||
into the doc). Consumers detect breaking changes via the major version.
|
||||
|
||||
- **Major** = breaking gateway change (an endpoint removed, a request
|
||||
field removed, a status code changed meaning).
|
||||
- **Minor** = additive (a new endpoint, a new optional request field).
|
||||
- **Patch** = wording (doc clarifications, description tweaks).
|
||||
|
||||
The version is a constant in `to_openapi` (bumped manually when the
|
||||
gateway contract changes), not derived from the registry's operation
|
||||
set. The initial version is `1.0.0`.
|
||||
|
||||
### Error fidelity (ADR-023, http-adapters.md §"Error Fidelity")
|
||||
|
||||
`to_openapi` projects `error_schemas` to the gateway endpoint's response
|
||||
definitions. The `/call` endpoint's responses include the
|
||||
operation-level errors (mapped by `http_status`), plus the protocol-
|
||||
level errors:
|
||||
|
||||
```yaml
|
||||
# /call endpoint responses
|
||||
responses:
|
||||
'200': { schema: <output_schema for the called operation> }
|
||||
'400': { schema: <INVALID_INPUT error> }
|
||||
'401': { schema: <no bearer token> }
|
||||
'403': { schema: <FORBIDDEN — insufficient scopes> }
|
||||
'404': { schema: <NOT_FOUND — operation not registered or Internal> }
|
||||
'422': { schema: <operation-level error with http_status=422> }
|
||||
'429': { schema: <operation-level error with http_status=429> }
|
||||
'500': { schema: <INTERNAL> }
|
||||
'504': { schema: <TIMEOUT> }
|
||||
```
|
||||
|
||||
The operation-level errors (with `http_status`) are surfaced on the
|
||||
`/call` endpoint's response — the gateway propagates the called
|
||||
operation's `error_schemas` as response definitions. This makes the
|
||||
adapter contract from ADR-017 faithful on the error axis — no silent
|
||||
dropping of error contracts.
|
||||
|
||||
### The `OpenAPISpec` type
|
||||
|
||||
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 Rust type, but
|
||||
not the same document shape: `from_openapi` consumes traditional
|
||||
per-operation-paths docs (one path per operation), while `to_openapi`
|
||||
produces the 5-endpoint gateway doc (ADR-042). The type is shared; the
|
||||
shape is not. Coordinate with the `from-openapi` task on the shared type.
|
||||
|
||||
### Traditional per-operation-paths projection (additive, out of scope)
|
||||
|
||||
A deployment that wants a traditional REST OpenAPI doc (per-operation
|
||||
paths with split parameters) can build it as a separate projection with
|
||||
HTTP-specific metadata (which fields are path params, etc.). The gateway
|
||||
pattern is the default `to_openapi` projection; the traditional
|
||||
projection is additive, not a replacement (ADR-042 §5). This task
|
||||
implements the gateway projection only; the traditional projection is
|
||||
out of scope.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] `to_openapi(registry: &OperationRegistry) -> OpenAPISpec` implemented
|
||||
- [ ] Generates 5 fixed gateway endpoints (`/search`, `/schema`, `/call`, `/batch`, `/subscribe`)
|
||||
- [ ] No per-operation paths (the gateway is the surface, ADR-042)
|
||||
- [ ] `/call` request body is flat JSON `{ operation, input }` (no path/query/body split)
|
||||
- [ ] `/subscribe` response is `text/event-stream`
|
||||
- [ ] `info.version` is semver tracking the gateway contract (initial `1.0.0`, ADR-045)
|
||||
- [ ] Per-caller operation surface NOT preloaded into the doc (discovered via `/search`)
|
||||
- [ ] `/call` responses include protocol-level errors (400, 401, 403, 404, 500, 504)
|
||||
- [ ] `/call` responses include operation-level errors (mapped by `http_status`, ADR-023)
|
||||
- [ ] `HTTP_<status>`-prefixed error codes projected correctly (no collision with protocol codes)
|
||||
- [ ] `to_openapi` is a pure projection (does not modify registry, not an OperationAdapter)
|
||||
- [ ] `GET /openapi.json` route serves the generated spec (wired by http-adapter task)
|
||||
- [ ] Unit test: generated doc has exactly 5 paths (the gateway endpoints)
|
||||
- [ ] Unit test: `/call` request schema is `{ operation: string, input: object }`
|
||||
- [ ] Unit test: `/subscribe` response content type is `text/event-stream`
|
||||
- [ ] Unit test: `info.version` is `1.0.0`
|
||||
- [ ] Unit test: `/call` responses include all protocol-level error statuses
|
||||
- [ ] Unit test: operation with `error_schemas` → those errors projected on `/call`
|
||||
- [ ] Unit test: operation with `HTTP_404` error code → projected as 404 response
|
||||
- [ ] `cargo test -p alknet-http` succeeds
|
||||
- [ ] `cargo clippy -p alknet-http --all-targets` succeeds with no warnings
|
||||
|
||||
## References
|
||||
|
||||
- docs/architecture/crates/http/http-adapters.md — to_openapi (§"to_openapi", §"The gateway endpoint set", §"Per-caller API surface", §"Error Fidelity")
|
||||
- docs/architecture/decisions/042-openapi-gateway-pattern.md — ADR-042 (5 fixed gateway endpoints)
|
||||
- docs/architecture/decisions/045-to-openapi-gateway-spec-versioning.md — ADR-045 (info.version semver)
|
||||
- docs/architecture/decisions/047-remove-direct-call-http-surface.md — ADR-047 (gateway is sole invoke path)
|
||||
- docs/architecture/decisions/023-operation-error-schemas.md — ADR-023 (error fidelity, HTTP_<status> prefix)
|
||||
- docs/architecture/decisions/017-call-protocol-client-and-adapter-contract.md — ADR-017 §5 (to_* are projections)
|
||||
|
||||
## Notes
|
||||
|
||||
> to_openapi is a pure projection — it consumes the registry, does not
|
||||
> produce entries. The generated doc describes the 5 fixed gateway
|
||||
> endpoints (stable, same for every caller); the per-caller operation
|
||||
> surface is discovered via /search, not preloaded. The info.version
|
||||
> semver tracks the gateway endpoint contract, not the operation set
|
||||
> (ADR-045) — per-caller operation changes do not bump the version. The
|
||||
> error fidelity (ADR-023) projects operation-level errors (with
|
||||
> http_status) onto /call's responses, plus the protocol-level errors.
|
||||
> The OpenAPISpec type is shared with from_openapi (coordinate on the
|
||||
> type); the shape is not (from_openapi consumes per-operation-paths,
|
||||
> to_openapi produces the 5-endpoint gateway doc). The traditional
|
||||
> per-operation-paths projection is additive (ADR-042 §5) and out of
|
||||
> scope.
|
||||
|
||||
## Summary
|
||||
|
||||
> To be filled on completion
|
||||
173
tasks/http/client/shared-http-client.md
Normal file
173
tasks/http/client/shared-http-client.md
Normal file
@@ -0,0 +1,173 @@
|
||||
---
|
||||
id: http/client/shared-http-client
|
||||
name: Implement shared HTTP client (ClientWithMiddleware + retry + Retry-After, OQ-40)
|
||||
status: pending
|
||||
depends_on: [http/crate-init]
|
||||
scope: narrow
|
||||
risk: low
|
||||
impact: component
|
||||
level: implementation
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
Implement the shared HTTP client in `src/client/http_client.rs`. This is
|
||||
the `reqwest_middleware::ClientWithMiddleware` used by all
|
||||
`from_openapi`/`from_mcp` forwarding handlers. The client owns connection
|
||||
pooling, keep-alive, TLS, and a retry stack. It is constructed once and
|
||||
reused across all forwarding handlers; 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 middleware stack (OQ-40 resolved)
|
||||
|
||||
The shared type is `reqwest_middleware::ClientWithMiddleware`, not a
|
||||
bare `reqwest::Client` — both retry and Retry-After are middleware on the
|
||||
stack, and middleware requires the `ClientWithMiddleware` wrapper. The
|
||||
stack has two layers:
|
||||
|
||||
1. **`RetryTransientMiddleware`** (from `reqwest-retry`) — exponential
|
||||
backoff on transient failures (connection errors, 5xx). The "retry N
|
||||
times with increasing intervals" part. Configured via an
|
||||
`ExponentialBackoff` policy at client construction.
|
||||
|
||||
2. **Inlined `RetryAfterMiddleware`** — parses the `Retry-After` header
|
||||
on 429/503 and sleeps before the next request to that URL. The
|
||||
"respect what the server told you" part. **Inlined** (MIT, ~50 lines
|
||||
of real logic) from `melotic/reqwest-retry-after`, not pulled as a
|
||||
dependency: the crate is complementary to `reqwest-retry` (whose
|
||||
default strategy does not honor `Retry-After`), and inlining lets the
|
||||
upstream's unbounded `HashMap<Url, SystemTime>` storage be bounded
|
||||
for a long-running process.
|
||||
|
||||
### Pooling, keep-alive, TLS
|
||||
|
||||
Pooling, keep-alive, and TLS come from `reqwest::ClientBuilder` defaults;
|
||||
outbound TLS uses the system trust store (standard HTTPS to external
|
||||
APIs like OpenAI, Anthropic). Custom CA bundle + client certs are an
|
||||
optional config for self-hosted API gateways (two-way-door
|
||||
implementation detail; the credential comes from `Capabilities`, the TLS
|
||||
trust comes from the system).
|
||||
|
||||
### Hot-reload (rebuild-and-swap)
|
||||
|
||||
Hot-reload of the pooling/retry config is **rebuild-and-swap**: a config
|
||||
change rebuilds the `ClientWithMiddleware` and swaps it via `ArcSwap`
|
||||
(the same pattern `ConfigIdentityProvider` uses, ADR-035). A rebuild
|
||||
drops the connection pool / keep-alive state, which is acceptable — a
|
||||
config change wanting a fresh pool is the case that triggers it. The
|
||||
retry policy is baked into the middleware at `ClientBuilder::build()`
|
||||
time; live policy mutation is not supported by `reqwest-retry`, so cheap
|
||||
per-policy updates are not part of the model.
|
||||
|
||||
### API
|
||||
|
||||
```rust
|
||||
/// Configuration for the shared HTTP client (two-way-door, OQ-40 resolved).
|
||||
pub struct HttpClientConfig {
|
||||
/// Pool max idle connections per host (reqwest default if None).
|
||||
pub pool_max_idle_per_host: Option<usize>,
|
||||
/// Request timeout (reqwest default if None).
|
||||
pub request_timeout: Option<Duration>,
|
||||
/// Retry policy for transient failures (ExponentialBackoff).
|
||||
pub retry_policy: ExponentialBackoff,
|
||||
/// Custom CA bundle path for self-hosted API gateways (optional).
|
||||
pub ca_bundle: Option<PathBuf>,
|
||||
/// Client cert for mutual TLS (optional, from Capabilities at call time,
|
||||
/// not here — this is the trust config, not the credential).
|
||||
pub client_cert: Option<ClientCertConfig>,
|
||||
}
|
||||
|
||||
/// The shared HTTP client. Constructed once, reused across all forwarding
|
||||
/// handlers. Wrapped in `ArcSwap` for rebuild-and-swap hot-reload.
|
||||
pub struct SharedHttpClient {
|
||||
inner: ArcSwap<ClientWithMiddleware>,
|
||||
config: ArcSwap<HttpClientConfig>,
|
||||
}
|
||||
|
||||
impl SharedHttpClient {
|
||||
pub fn new(config: HttpClientConfig) -> Self { ... }
|
||||
|
||||
/// Get the current client (for forwarding handlers to use).
|
||||
pub fn client(&self) -> Arc<ClientWithMiddleware> {
|
||||
self.inner.load_full()
|
||||
}
|
||||
|
||||
/// Rebuild the client with new config (rebuild-and-swap hot-reload).
|
||||
pub fn reload(&self, config: HttpClientConfig) { ... }
|
||||
}
|
||||
```
|
||||
|
||||
### The inlined `RetryAfterMiddleware`
|
||||
|
||||
The inlined middleware is ~50 lines: a bounded `HashMap<Url, SystemTime>`
|
||||
holding per-URL retry-after deadlines, a `before` hook that sleeps if the
|
||||
deadline is in the future, and an `after` hook that parses the
|
||||
`Retry-After` header on 429/503 and records the deadline. The bound
|
||||
(e.g., LRU eviction at N entries) prevents unbounded growth in a
|
||||
long-running process — the upstream's unbounded `HashMap<Url, SystemTime>`
|
||||
is the reason it's inlined rather than depended on.
|
||||
|
||||
### Downstream layering boundary
|
||||
|
||||
The agent crate's provider SSE normalization sits on top of this
|
||||
`ClientWithMiddleware`: it consumes the `reqwest::Response` stream the
|
||||
forwarding handler produces and emits `call.responded` events. It does
|
||||
not replace the client or own transport/pooling/retry. `alknet-http`
|
||||
owns transport; the agent crate owns provider-specific SSE →
|
||||
Vercel-UI-message mapping. The aisdk `core/client.rs` reference for HTTP
|
||||
client construction is *not* carried forward — its env-var config and
|
||||
hand-rolled retry are the anti-patterns discarded in favor of the
|
||||
middleware stack above.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] `SharedHttpClient` struct in `src/client/http_client.rs`
|
||||
- [ ] Wraps `ArcSwap<ClientWithMiddleware>` for rebuild-and-swap
|
||||
- [ ] `HttpClientConfig` with pool, timeout, retry policy, optional CA bundle
|
||||
- [ ] `RetryTransientMiddleware` (from `reqwest-retry`) in the middleware stack
|
||||
- [ ] Inlined `RetryAfterMiddleware` (~50 lines, bounded `HashMap<Url, SystemTime>`)
|
||||
- [ ] `RetryAfterMiddleware` parses `Retry-After` header on 429/503
|
||||
- [ ] `RetryAfterMiddleware` sleeps before next request to a URL with an active deadline
|
||||
- [ ] `RetryAfterMiddleware` storage is bounded (LRU eviction or equivalent)
|
||||
- [ ] Pooling/keep-alive/TLS from `reqwest::ClientBuilder` defaults
|
||||
- [ ] System trust store for outbound TLS by default
|
||||
- [ ] Custom CA bundle optional (self-hosted API gateways)
|
||||
- [ ] `reload(config)` rebuilds and swaps via `ArcSwap`
|
||||
- [ ] Credential injection is per-request (NOT at client construction)
|
||||
- [ ] No `std::env::var` reads (no-env-vars invariant — ADR-014)
|
||||
- [ ] No shared global client (alknet-http owns its client)
|
||||
- [ ] Unit test: `client()` returns a usable `ClientWithMiddleware`
|
||||
- [ ] Unit test: `reload()` swaps the client (new client returned by `client()`)
|
||||
- [ ] Unit test: `RetryAfterMiddleware` records deadline from `Retry-After: <seconds>`
|
||||
- [ ] Unit test: `RetryAfterMiddleware` records deadline from `Retry-After: <HTTP-date>`
|
||||
- [ ] Unit test: bounded storage evicts old entries (no unbounded growth)
|
||||
- [ ] `cargo test -p alknet-http` succeeds
|
||||
- [ ] `cargo clippy -p alknet-http --all-targets` succeeds with no warnings
|
||||
|
||||
## References
|
||||
|
||||
- docs/architecture/crates/http/http-adapters.md — HTTP client (§"HTTP client (reqwest)")
|
||||
- docs/architecture/crates/http/overview.md — OQ-40 resolved (ClientWithMiddleware + middleware stack)
|
||||
- docs/architecture/decisions/014-secret-material-flow-and-capability-injection.md — ADR-014 (no env vars)
|
||||
- https://docs.rs/reqwest-retry/ — RetryTransientMiddleware / ExponentialBackoff
|
||||
- https://github.com/melotic/reqwest-retry-after — RetryAfterMiddleware source (MIT, inlined, not a dependency)
|
||||
|
||||
## Notes
|
||||
|
||||
> The shared HTTP client is the no-env-vars-compliant outbound transport.
|
||||
> The middleware stack (RetryTransientMiddleware + inlined
|
||||
> RetryAfterMiddleware) is the resolved shape (OQ-40). The
|
||||
> RetryAfterMiddleware is inlined (not a dependency) so its storage can
|
||||
> be bounded for a long-running process — the upstream's unbounded
|
||||
> HashMap is the reason. Hot-reload is rebuild-and-swap via ArcSwap (same
|
||||
> pattern as ConfigIdentityProvider, ADR-035). Credential injection is
|
||||
> per-request (from OperationContext.capabilities), not at client
|
||||
> construction — the client is shared, the credentials are per-call. The
|
||||
> agent crate's SSE normalization sits on top of this client; it does not
|
||||
> replace it.
|
||||
|
||||
## Summary
|
||||
|
||||
> To be filled on completion
|
||||
146
tasks/http/crate-init.md
Normal file
146
tasks/http/crate-init.md
Normal file
@@ -0,0 +1,146 @@
|
||||
---
|
||||
id: http/crate-init
|
||||
name: Initialize alknet-http crate with Cargo.toml, dependencies, and module skeleton
|
||||
status: pending
|
||||
depends_on: []
|
||||
scope: moderate
|
||||
risk: low
|
||||
impact: project
|
||||
level: implementation
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
Initialize the `alknet-http` crate from scratch. This crate is the HTTP
|
||||
interface for alknet: it serves inbound HTTP on standard ALPNs (`h2`,
|
||||
`http/1.1`, with WebSocket upgrade for browser bidirectional access to the
|
||||
call protocol) and hosts the HTTP-backed call-protocol adapters
|
||||
(`from_openapi`, `to_openapi`, `from_mcp`, `to_mcp`). HTTP/3 + WebTransport
|
||||
(`h3`) is deferred per ADR-044 and is **not** part of this crate's initial
|
||||
release.
|
||||
|
||||
The crate has two roles in one (ADR-039): HTTP server (`HttpAdapter`, a
|
||||
`ProtocolHandler` for `h2`/`http/1.1` + WS upgrade) and HTTP client host
|
||||
(`from_openapi`/`from_mcp` forwarding via `reqwest`, `to_openapi`/`to_mcp`
|
||||
projections via `axum`). Both directions share the same HTTP dependencies,
|
||||
which is why they live in one crate.
|
||||
|
||||
### Crate setup
|
||||
|
||||
Create `crates/alknet-http/` with:
|
||||
|
||||
- `Cargo.toml` — package metadata, dependencies, feature gates
|
||||
- `src/lib.rs` — crate root with module declarations and re-exports
|
||||
- Module skeleton files for:
|
||||
- `src/server/mod.rs` — `HttpAdapter`, axum-over-QUIC, gateway routes,
|
||||
`/healthz`, decoy, custom routes
|
||||
- `src/gateway/mod.rs` — shared dispatch spine (`GatewayDispatch`),
|
||||
error mapping
|
||||
- `src/client/mod.rs` — shared HTTP client (`ClientWithMiddleware`)
|
||||
- `src/adapters/mod.rs` — `from_openapi`, `to_openapi`, `from_mcp`,
|
||||
`to_mcp`
|
||||
- `src/websocket/mod.rs` — WS upgrade handler, framing, dispatch handoff
|
||||
|
||||
### Dependencies
|
||||
|
||||
| Crate | Purpose | Feature gate |
|
||||
|-------|---------|--------------|
|
||||
| `alknet-core` | `ProtocolHandler`, `Connection`, `AuthContext`, `Capabilities`, `IdentityProvider`, `AuthToken`, `Identity`, `HandlerError` (workspace path) | default |
|
||||
| `alknet-call` | `OperationAdapter`, `AdapterError`, `OperationSpec`, `Handler`, `HandlerRegistration`, `OperationRegistry`, `OperationProvenance`, `OperationContext`, `ResponseEnvelope`, `CallError`, `EventEnvelope`, `Dispatcher`, `CallConnection` (workspace path) | default |
|
||||
| `axum` | HTTP server — `Router`, extractors, middleware, WebSocket upgrade | default |
|
||||
| `hyper` | HTTP/1.1 + HTTP/2 framing (axum is built on hyper) | default |
|
||||
| `reqwest` | HTTP client — `from_openapi`/`from_mcp` forwarding | default |
|
||||
| `reqwest-middleware` | `ClientWithMiddleware` wrapper for middleware stack | default |
|
||||
| `reqwest-retry` | `RetryTransientMiddleware` / `ExponentialBackoff` | default |
|
||||
| `tokio` 1 (full) | Async runtime | default |
|
||||
| `serde` 1 | Serialization | default |
|
||||
| `serde_json` 1 | JSON wire format, JSON Schema values | default |
|
||||
| `async-trait` 0.1 | Async traits | default |
|
||||
| `tracing` 0.1 | Structured logging | default |
|
||||
| `thiserror` 2 | Error enums | default |
|
||||
| `uuid` 1 | Request ID generation (UUID v4) | default |
|
||||
| `futures` | Stream trait for SSE / subscriptions | default |
|
||||
| `rmcp` | MCP streamable HTTP — `from_mcp`/`to_mcp` | `mcp` feature |
|
||||
| `openapiv3` | OpenAPI document types (or a local type — two-way door) | default |
|
||||
|
||||
### Feature gates
|
||||
|
||||
```toml
|
||||
[features]
|
||||
default = ["h2", "http1"]
|
||||
mcp = ["dep:rmcp"]
|
||||
# h3 (HTTP/3 + WebTransport) is deferred per ADR-044 — not in the v1
|
||||
# feature set. The browser bidirectional path uses WebSocket (native to
|
||||
# axum, no feature gate). When WebTransport revives, the `h3` feature
|
||||
# gate returns; see ADR-044 and webtransport.md.
|
||||
```
|
||||
|
||||
- `h2` + `http1` (default): the `axum` + `hyper` HTTP/1.1 + HTTP/2 server,
|
||||
including WebSocket upgrade for browser bidirectional access (ADR-044).
|
||||
- `mcp`: the `rmcp` dependency with streamable HTTP transport features
|
||||
only (ADR-037 — stdio is excluded). Adds `from_mcp`/`to_mcp`.
|
||||
|
||||
### Workspace Cargo.toml
|
||||
|
||||
Add `crates/alknet-http` to the workspace `members` list in the root
|
||||
`Cargo.toml`.
|
||||
|
||||
### Module skeleton
|
||||
|
||||
```rust
|
||||
// src/lib.rs
|
||||
//! alknet-http: HTTP interface for alknet — serves HTTP/1.1 + HTTP/2 on
|
||||
//! standard ALPNs (with WebSocket upgrade for browser bidirectional access)
|
||||
//! and hosts the HTTP-backed call-protocol adapters.
|
||||
//!
|
||||
//! Two roles in one crate (ADR-039): HTTP server (HttpAdapter, a
|
||||
//! ProtocolHandler for h2/http1.1 + WS upgrade) and HTTP client host
|
||||
//! (from_openapi/from_mcp forwarding, to_openapi/to_mcp projections).
|
||||
|
||||
pub mod server;
|
||||
pub mod gateway;
|
||||
pub mod client;
|
||||
pub mod adapters;
|
||||
pub mod websocket;
|
||||
```
|
||||
|
||||
Each module file gets a doc comment and `// TODO: implement` marker.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] `crates/alknet-http/Cargo.toml` exists with all dependencies and feature gates
|
||||
- [ ] `crates/alknet-http/src/lib.rs` exists with module declarations
|
||||
- [ ] All module skeleton files exist (server/, gateway/, client/, adapters/, websocket/)
|
||||
- [ ] Root `Cargo.toml` `members` list includes `crates/alknet-http`
|
||||
- [ ] `cargo check -p alknet-http` succeeds
|
||||
- [ ] `cargo clippy -p alknet-http` succeeds with no warnings
|
||||
- [ ] Dual licensing: `MIT OR Apache-2.0` (workspace-inherited)
|
||||
- [ ] `alknet-core` dependency uses workspace path (`path = "../alknet-core"`)
|
||||
- [ ] `alknet-call` dependency uses workspace path (`path = "../alknet-call"`)
|
||||
- [ ] `mcp` feature gate pulls in `rmcp` with streamable HTTP transport features only (no stdio)
|
||||
- [ ] `h3`/WebTransport dependency is absent (deferred per ADR-044)
|
||||
|
||||
## References
|
||||
|
||||
- docs/architecture/crates/http/README.md — crate index
|
||||
- docs/architecture/crates/http/overview.md — crate overview, dependencies, feature gates
|
||||
- docs/architecture/decisions/003-crate-decomposition.md — ADR-003 (Amendment 1: alknet-call is protocol-foundation)
|
||||
- docs/architecture/decisions/039-http-server-and-client-host-colocated.md — ADR-039 (one crate for server + client host)
|
||||
- docs/architecture/decisions/044-defer-webtransport-browsers-use-websocket.md — ADR-044 (h3 deferred)
|
||||
- docs/architecture/decisions/037-mcp-stdio-transport-exclusion.md — ADR-037 (streamable HTTP only)
|
||||
|
||||
## Notes
|
||||
|
||||
> alknet-http depends on alknet-core (ProtocolHandler, Connection,
|
||||
> IdentityProvider, Capabilities) and alknet-call (OperationAdapter,
|
||||
> OperationRegistry, HandlerRegistration, EventEnvelope, Dispatcher). The
|
||||
> crate has five subsystems: server (HttpAdapter, axum over QUIC), gateway
|
||||
> (shared dispatch spine, error mapping), client (shared HTTP client),
|
||||
> adapters (from_openapi/to_openapi/from_mcp/to_mcp), and websocket (WS
|
||||
> upgrade, native session). The module structure reflects this split. The
|
||||
> `mcp` feature gate is optional; the default feature set is `h2` + `http1`.
|
||||
> The `h3`/WebTransport dependency is absent (deferred per ADR-044).
|
||||
|
||||
## Summary
|
||||
|
||||
> To be filled on completion
|
||||
139
tasks/http/gateway/error-mapping.md
Normal file
139
tasks/http/gateway/error-mapping.md
Normal file
@@ -0,0 +1,139 @@
|
||||
---
|
||||
id: http/gateway/error-mapping
|
||||
name: Implement CallError-to-HTTP-status error mapping (ADR-023)
|
||||
status: pending
|
||||
depends_on: [http/crate-init]
|
||||
scope: narrow
|
||||
risk: low
|
||||
impact: component
|
||||
level: implementation
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
Implement the `CallError` code → HTTP status code mapping in
|
||||
`src/gateway/error.rs`. This is the error-mapping table the HTTP server's
|
||||
gateway endpoints use to translate call-protocol `CallError` codes into
|
||||
HTTP response status codes (ADR-023). 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.
|
||||
|
||||
### The mapping table (http-server.md §"Error Mapping")
|
||||
|
||||
| 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 `HTTP_<status>` prefix rule (ADR-023 §5)
|
||||
|
||||
`from_openapi` maps OpenAPI non-2xx response status codes to
|
||||
`ErrorDefinition`s with codes prefixed `HTTP_` + the status number:
|
||||
|
||||
```rust
|
||||
// OpenAPI: 404: { schema: NotFoundError }
|
||||
// → ErrorDefinition { code: "HTTP_404", http_status: Some(404), schema: NotFoundError }
|
||||
```
|
||||
|
||||
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 `HTTP_<status>`
|
||||
prefix enforces this.
|
||||
|
||||
### `retryable` → `Retry-After` hint
|
||||
|
||||
The `retryable` field from `CallError` maps to an HTTP `Retry-After` hint
|
||||
for `503`/`429`-class errors (operation-level codes with `http_status` in
|
||||
that range). The hint is optional; if the operation-level error does not
|
||||
carry a retry-after value, no header is added.
|
||||
|
||||
### API
|
||||
|
||||
```rust
|
||||
/// Map a CallError to an HTTP status code (ADR-023).
|
||||
pub fn call_error_to_http_status(error: &CallError) -> u16;
|
||||
|
||||
/// Map a CallError to an HTTP response, including the Retry-After hint
|
||||
/// when applicable. The body is the serialized CallError (or its
|
||||
/// `details` field).
|
||||
pub fn call_error_to_http_response(error: &CallError) -> axum::response::Response;
|
||||
```
|
||||
|
||||
The `FORBIDDEN` case needs the caller's identity state to distinguish
|
||||
`401` (no token) from `403` (token present but insufficient scopes). The
|
||||
mapping function takes an `Option<Identity>` (or a flag) so the gateway
|
||||
endpoint can pass the resolved identity through:
|
||||
|
||||
```rust
|
||||
/// Map a CallError to an HTTP status code, considering whether the caller
|
||||
/// was authenticated (FORBIDDEN → 401 if no identity, 403 if identity
|
||||
/// present but insufficient scopes).
|
||||
pub fn call_error_to_http_status_with_identity(
|
||||
error: &CallError,
|
||||
identity: Option<&Identity>,
|
||||
) -> u16;
|
||||
```
|
||||
|
||||
### What this task does NOT do
|
||||
|
||||
- **No `to_openapi` error projection.** `to_openapi` projects
|
||||
`error_schemas` to the gateway endpoint's response definitions (the
|
||||
OpenAPI doc's `responses` block). That is the `to-openapi` task, not
|
||||
this one. This task is the runtime HTTP response mapping.
|
||||
- **No `from_openapi` error import.** `from_openapi` builds
|
||||
`ErrorDefinition`s from OpenAPI non-2xx responses with the `HTTP_<status>`
|
||||
prefix. That is the `from-openapi` task. This task consumes the
|
||||
resulting `CallError` codes at runtime.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] `call_error_to_http_status(error: &CallError) -> u16` implemented
|
||||
- [ ] `NOT_FOUND` → 404
|
||||
- [ ] `FORBIDDEN` → 401 (no identity) / 403 (identity present)
|
||||
- [ ] `INVALID_INPUT` → 422
|
||||
- [ ] `TIMEOUT` → 504
|
||||
- [ ] `INTERNAL` → 500
|
||||
- [ ] Operation-level code with `http_status` → declared status
|
||||
- [ ] Operation-level code without `http_status` → 500
|
||||
- [ ] `HTTP_<status>`-prefixed codes (from `from_openapi`) → the status number
|
||||
- [ ] `call_error_to_http_response(error)` builds an `axum::response::Response` with the status + JSON body
|
||||
- [ ] `retryable: true` on `503`/`429`-class errors → `Retry-After` header (when value present)
|
||||
- [ ] `call_error_to_http_status_with_identity(error, identity)` for the 401/403 split
|
||||
- [ ] Unit test: each protocol code maps to the correct status
|
||||
- [ ] Unit test: operation-level code with `http_status` maps to declared status
|
||||
- [ ] Unit test: operation-level code without `http_status` maps to 500
|
||||
- [ ] Unit test: `HTTP_404` code maps to 404 (not collided with protocol `NOT_FOUND`)
|
||||
- [ ] Unit test: `FORBIDDEN` with `None` identity → 401
|
||||
- [ ] Unit test: `FORBIDDEN` with `Some(identity)` → 403
|
||||
- [ ] `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 — Error Mapping table (§"Error Mapping")
|
||||
- docs/architecture/crates/http/http-adapters.md — Error Fidelity (§"Error Fidelity (ADR-023)")
|
||||
- docs/architecture/decisions/023-operation-error-schemas.md — ADR-023 (protocol/operation codes distinct, HTTP_<status> prefix)
|
||||
|
||||
## Notes
|
||||
|
||||
> 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>. The FORBIDDEN
|
||||
> case needs the caller's identity state to distinguish 401 (no token)
|
||||
> from 403 (token present but insufficient scopes). This task is the
|
||||
> runtime HTTP response mapping; the to_openapi doc-level error
|
||||
> projection is the to-openapi task, and the from_openapi error import
|
||||
> is the from-openapi task.
|
||||
|
||||
## Summary
|
||||
|
||||
> To be filled on completion
|
||||
183
tasks/http/gateway/gateway-dispatch-spine.md
Normal file
183
tasks/http/gateway/gateway-dispatch-spine.md
Normal file
@@ -0,0 +1,183 @@
|
||||
---
|
||||
id: http/gateway/gateway-dispatch-spine
|
||||
name: Implement GatewayDispatch shared dispatch spine (thin concrete struct, not a trait)
|
||||
status: pending
|
||||
depends_on: [http/crate-init]
|
||||
scope: narrow
|
||||
risk: medium
|
||||
impact: component
|
||||
level: implementation
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
Implement the shared dispatch spine for the `to_*` gateway projections
|
||||
(`to_openapi`, `to_mcp`) in `src/gateway/dispatch.rs`. This is the thin
|
||||
shared core recommended by the gateway-factoring research: a **concrete
|
||||
struct, not a trait**. It holds `Arc<OperationRegistry>` +
|
||||
`Arc<dyn IdentityProvider>` and exposes a `resolve_bearer()` +
|
||||
`invoke()` method pair returning the neutral `ResponseEnvelope`. Both
|
||||
gateways call it as a library function; each gateway then maps the
|
||||
`ResponseEnvelope` to its own wire shape.
|
||||
|
||||
This is the security-relevant shared piece: identity resolution, root
|
||||
`OperationContext` construction, and the `OperationRegistry::invoke()`
|
||||
call. A divergence here (one gateway resolving identity differently, or
|
||||
building `OperationContext` with a different `internal` flag, or mapping
|
||||
`CallError` inconsistently) would be a real security/correctness bug.
|
||||
Extracting the spine now makes the two gateways *provably* identical on
|
||||
the security-relevant axis (auth, authority, ACL) and lets them diverge
|
||||
only on the wire-framing axis (where divergence is correct).
|
||||
|
||||
### The struct (research §5.1)
|
||||
|
||||
```rust
|
||||
/// Shared dispatch spine for the `to_*` gateway projections.
|
||||
/// Resolves identity, builds a root OperationContext, invokes the registry,
|
||||
/// returns the neutral ResponseEnvelope. Each gateway maps the envelope to
|
||||
/// its own wire shape.
|
||||
pub struct GatewayDispatch {
|
||||
registry: Arc<OperationRegistry>,
|
||||
identity_provider: Arc<dyn IdentityProvider>,
|
||||
}
|
||||
|
||||
impl GatewayDispatch {
|
||||
pub fn new(
|
||||
registry: Arc<OperationRegistry>,
|
||||
identity_provider: Arc<dyn IdentityProvider>,
|
||||
) -> Self { ... }
|
||||
|
||||
/// Resolve a bearer token to an Identity (shared by both gateways'
|
||||
/// axum auth middleware).
|
||||
pub fn resolve_bearer(&self, token: &AuthToken) -> Option<Identity> {
|
||||
self.identity_provider.resolve_from_token(token)
|
||||
}
|
||||
|
||||
/// Invoke an operation as a wire-ingress caller. `internal: false`,
|
||||
/// `forwarded_for: None`, fresh request_id. Returns the neutral
|
||||
/// ResponseEnvelope; the gateway maps it to its wire shape.
|
||||
pub async fn invoke(
|
||||
&self,
|
||||
identity: Option<Identity>,
|
||||
op: &str,
|
||||
input: Value,
|
||||
) -> ResponseEnvelope {
|
||||
// build root OperationContext, call self.registry.invoke(op, input, ctx)
|
||||
// ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Root OperationContext construction
|
||||
|
||||
The `invoke()` method builds a root `OperationContext` for a wire-ingress
|
||||
call (the same shape `CallAdapter::build_root_context` builds for
|
||||
`alknet/call` wire requests):
|
||||
|
||||
- `internal: false` — ACL runs against the caller's `identity`, not a
|
||||
handler's composition authority (ADR-015).
|
||||
- `forwarded_for: None` — wire-ingress only (ADR-032).
|
||||
- `identity` = the resolved bearer identity (from `resolve_bearer`).
|
||||
- `handler_identity` = the registration bundle's `composition_authority`.
|
||||
- `capabilities` = the registration bundle's capabilities.
|
||||
- `scoped_env` = the registration bundle's `scoped_env` (or empty).
|
||||
- `request_id` = fresh UUID v4 (`generate_request_id()`).
|
||||
- `deadline` = `now + default_timeout` (30s default).
|
||||
- `env` = a `LocalOperationEnv` over the registry (the gateway dispatch
|
||||
path does not compose peer/session overlays — it is a flat invoke).
|
||||
|
||||
Coordinate with the existing `Dispatcher::build_root_context` in
|
||||
alknet-call (`protocol/dispatch.rs`): if that logic can be extracted as a
|
||||
shared free function (it should be — it takes `identity`, `capabilities`,
|
||||
`env`, `deadline` and returns an `OperationContext`), call it from both
|
||||
`Dispatcher` and `GatewayDispatch`. If it is tangled with
|
||||
`CallAdapter`-specific state, duplicate the construction logic here (the
|
||||
invariants — `internal: false`, `forwarded_for: None` — are the
|
||||
load-bearing part; the construction itself is mechanical). See research
|
||||
§6 open question #1.
|
||||
|
||||
### What this task does NOT do
|
||||
|
||||
- **No `GatewayDispatch` trait.** A concrete struct, not a polymorphic
|
||||
trait. The research (§5.2) rules this out: a trait would need an
|
||||
associated output type (HTTP `Response` vs `CallToolResult`), at which
|
||||
point it has no shared method bodies.
|
||||
- **No `into_wire()` method.** The `ResponseEnvelope` → wire mapping is
|
||||
per-gateway; do not parameterize the core over it.
|
||||
- **No streaming abstraction.** `/subscribe` SSE is `to_openapi`-only; do
|
||||
not build a `GatewayStream` trait for one implementation.
|
||||
- **No discovery abstraction.** `services/list` is the shared backend
|
||||
(already in `OperationRegistry`); the discovery *framing* (OpenAPI
|
||||
`/search` vs MCP `tools/list` + `search` tool) is per-gateway.
|
||||
- **No versioning.** `info.version` is `to_openapi`-only.
|
||||
- **No `batch` method.** `batch` is a loop over `invoke()` in each
|
||||
gateway (research §6 open question #3 — confirm `batch` is genuinely
|
||||
just a loop, no shared batch-specific state).
|
||||
|
||||
### `services/list` / `services/schema` dispatch
|
||||
|
||||
The gateway's `search`/`schema` endpoints/tools dispatch `services/list`
|
||||
and `services/schema` — these are registered operations in the
|
||||
`OperationRegistry`, so `GatewayDispatch::invoke()` handles them
|
||||
unchanged (it calls `OperationRegistry::invoke()`, which works for
|
||||
`services/list` and `services/schema`). The `AccessControl`-filtered
|
||||
listing lives in the `services/list` handler (already in the registry),
|
||||
not in the gateway. Confirm via a spike that the filtering sees the
|
||||
*caller's* identity when invoked through `GatewayDispatch::invoke` (it
|
||||
should — `services/list` is `AccessControl::check(identity)`-filtered,
|
||||
and `GatewayDispatch` passes the resolved identity as the caller). See
|
||||
research §6 open question #4.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] `GatewayDispatch` struct defined in `src/gateway/dispatch.rs`
|
||||
- [ ] Holds `Arc<OperationRegistry>` + `Arc<dyn IdentityProvider>`
|
||||
- [ ] `resolve_bearer(&self, token: &AuthToken) -> Option<Identity>` delegates to `identity_provider.resolve_from_token`
|
||||
- [ ] `invoke(&self, identity, op, input) -> ResponseEnvelope` builds root context and dispatches
|
||||
- [ ] Root `OperationContext` has `internal: false`, `forwarded_for: None`, fresh `request_id`
|
||||
- [ ] `handler_identity` from registration bundle's `composition_authority`
|
||||
- [ ] `capabilities` from registration bundle
|
||||
- [ ] `scoped_env` from registration bundle (or empty)
|
||||
- [ ] `deadline` = `now + 30s` default
|
||||
- [ ] `invoke()` calls `OperationRegistry::invoke(op, input, ctx)`
|
||||
- [ ] `invoke()` works for `services/list` and `services/schema` (registered ops)
|
||||
- [ ] `AccessControl`-filtering in `services/list` sees the caller's resolved identity
|
||||
- [ ] No `GatewayDispatch` trait (concrete struct only)
|
||||
- [ ] No `into_wire()` method (per-gateway mapping stays out of the core)
|
||||
- [ ] No streaming abstraction (per-gateway)
|
||||
- [ ] `GatewayDispatch` is `pub` and re-exported from `lib.rs`
|
||||
- [ ] Unit test: `invoke()` dispatches a registered op and returns `ResponseEnvelope`
|
||||
- [ ] Unit test: `invoke()` for `services/list` returns AccessControl-filtered list
|
||||
- [ ] Unit test: `invoke()` for unregistered op returns `CallError { code: NOT_FOUND }`
|
||||
- [ ] Unit test: `invoke()` for Internal op returns `CallError { code: NOT_FOUND }` (not leaked)
|
||||
- [ ] Unit test: `invoke()` with `None` identity + restricted op → `FORBIDDEN`
|
||||
- [ ] `cargo test -p alknet-http` succeeds
|
||||
- [ ] `cargo clippy -p alknet-http --all-targets` succeeds with no warnings
|
||||
|
||||
## References
|
||||
|
||||
- docs/research/alknet-http-gateway-factoring/findings.md — research recommendation (thin shared core, not a trait)
|
||||
- docs/architecture/crates/http/http-adapters.md — to_openapi dispatch (§"Shared dispatch spine with to_mcp")
|
||||
- docs/architecture/crates/http/http-mcp.md — to_mcp dispatch (§"Shared dispatch spine with to_openapi")
|
||||
- docs/architecture/crates/call/operation-registry.md — OperationRegistry::invoke(), OperationContext construction
|
||||
- docs/architecture/decisions/015-privilege-model-and-authority-context.md — ADR-015 (internal: false for wire)
|
||||
- docs/architecture/decisions/032-forwarded-for-identity.md — ADR-032 (forwarded_for: None for wire-ingress)
|
||||
|
||||
## Notes
|
||||
|
||||
> The dispatch spine is the security-relevant shared piece. A divergence
|
||||
> here (identity resolution, context construction, invoke shape) would be
|
||||
> a security bug; extracting the spine now makes the two gateways provably
|
||||
> identical on the security axis. The research recommends a concrete
|
||||
> struct, not a trait — a trait would need an associated output type
|
||||
> (HTTP Response vs CallToolResult), at which point it has no shared
|
||||
> method bodies. Coordinate with the existing
|
||||
> `Dispatcher::build_root_context` in alknet-call: if it can be extracted
|
||||
> as a shared free function, call it from both Dispatcher and
|
||||
> GatewayDispatch; otherwise duplicate the construction logic (the
|
||||
> invariants are the load-bearing part). The `batch` endpoint is a loop
|
||||
> over `invoke()` in each gateway, not a shared method.
|
||||
|
||||
## Summary
|
||||
|
||||
> To be filled on completion
|
||||
179
tasks/http/review-http-final.md
Normal file
179
tasks/http/review-http-final.md
Normal file
@@ -0,0 +1,179 @@
|
||||
---
|
||||
id: http/review-http-final
|
||||
name: Final review of alknet-http crate — all components, feature gates, pattern consistency
|
||||
status: pending
|
||||
depends_on: [http/review-http, http/review-websocket, http/review-mcp]
|
||||
scope: broad
|
||||
risk: low
|
||||
impact: project
|
||||
level: review
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
Final review of the entire `alknet-http` crate — all components together,
|
||||
feature gate isolation, pattern consistency, and cross-cutting concerns.
|
||||
This is the last quality checkpoint before the crate is considered
|
||||
complete. It runs after the three phase reviews (server surface,
|
||||
WebSocket, MCP) and verifies the crate as a whole.
|
||||
|
||||
### Review Checklist
|
||||
|
||||
1. **Crate structure**:
|
||||
- `crates/alknet-http/Cargo.toml` with all dependencies and feature gates
|
||||
- `src/lib.rs` with module declarations and re-exports
|
||||
- Module structure: `server/`, `gateway/`, `client/`, `adapters/`, `websocket/`
|
||||
- Root `Cargo.toml` `members` includes `crates/alknet-http`
|
||||
- Workspace path deps for `alknet-core`, `alknet-call`
|
||||
|
||||
2. **Feature gate isolation**:
|
||||
- `default = ["h2", "http1"]` — the HTTP surface (incl. WebSocket upgrade)
|
||||
- `mcp = ["dep:rmcp"]` — from_mcp/to_mcp (streamable HTTP only, ADR-037)
|
||||
- `h3`/WebTransport feature gate is ABSENT (deferred per ADR-044)
|
||||
- `cargo check -p alknet-http` (default features) succeeds
|
||||
- `cargo check -p alknet-http --features mcp` succeeds
|
||||
- `cargo check -p alknet-http --no-default-features` succeeds (if meaningful)
|
||||
- MCP code (from_mcp, to_mcp) not compiled without `mcp` feature
|
||||
- stdio transport (`transport-child-process`) is NOT a dependency
|
||||
|
||||
3. **Dependency correctness**:
|
||||
- `alknet-core` (ProtocolHandler, Connection, IdentityProvider, Capabilities)
|
||||
- `alknet-call` (OperationAdapter, AdapterError, OperationRegistry, HandlerRegistration, Dispatcher, CallConnection, EventEnvelope, ResponseEnvelope, CallError)
|
||||
- `axum` (HTTP server, WebSocket upgrade)
|
||||
- `reqwest` + `reqwest-middleware` + `reqwest-retry` (HTTP client)
|
||||
- `hyper` (HTTP/1.1 + HTTP/2 framing)
|
||||
- `rmcp` (MCP, feature-gated, streamable HTTP only)
|
||||
- No `wtransport` / `h3` dependency (deferred per ADR-044)
|
||||
- No env-var-based config (no-env-vars invariant, ADR-014)
|
||||
|
||||
4. **Cross-cutting conformance**:
|
||||
- **No-env-vars invariant** (ADR-014): no `std::env::var` in any handler
|
||||
(from_openapi, from_mcp forwarding handlers read context.capabilities)
|
||||
- **No secret material in responses** (ADR-014): Capabilities not serialized
|
||||
into HTTP response bodies or CallToolResult
|
||||
- **AccessControl is the sole gate** (ADR-029 §3): no remote_safe/trusted_peer
|
||||
- **Internal ops → NOT_FOUND** (ADR-015): don't leak existence from wire
|
||||
- **Error fidelity** (ADR-023): HTTP_<status> prefix for imported codes,
|
||||
no collision with protocol codes
|
||||
- **Browsers/MCP clients are not peers** (ADR-034 §4, ADR-044 §5):
|
||||
no PeerId, connection-local overlay
|
||||
|
||||
5. **Pattern consistency across the crate**:
|
||||
- `GatewayDispatch` is a concrete struct (not a trait) — shared by to_openapi, to_mcp
|
||||
- Auth middleware is shared (HTTP routes + to_mcp rmcp service)
|
||||
- `SharedHttpClient` is ArcSwap-wrapped (rebuild-and-swap, same pattern as ConfigIdentityProvider)
|
||||
- Error mapping is a free function (not a trait method)
|
||||
- `OperationAdapter` implementations (from_openapi, from_mcp) follow the same shape:
|
||||
parse/discover → construct HandlerRegistration bundles → return
|
||||
- `to_*` projections (to_openapi, to_mcp) are pure (consume registry, don't produce entries)
|
||||
- `from_*` adapters (from_openapi, from_mcp) are OperationAdapter impls (produce entries)
|
||||
|
||||
6. **ADR conformance (full list)**:
|
||||
- ADR-003 Am. 1: alknet-http depends on alknet-call (protocol-foundation)
|
||||
- ADR-004: Bearer auth via resolve_from_token
|
||||
- ADR-010: stealth mode (HTTP handler on standard ALPNs, decoy)
|
||||
- ADR-012: stream-agnostic correlation (Dispatcher over WS)
|
||||
- ADR-014: no-env-vars, no secret material in responses
|
||||
- ADR-015: Internal ops → 404 from wire
|
||||
- ADR-016: abort cascade on disconnect
|
||||
- ADR-017: OperationAdapter trait, to_* are projections
|
||||
- ADR-022: leaf provenance (from_openapi, from_mcp)
|
||||
- ADR-023: error fidelity, HTTP_<status> prefix
|
||||
- ADR-024: Layer 2 connection-local overlay (WS browser ops)
|
||||
- ADR-029 §3: AccessControl is sole gate (no remote_safe)
|
||||
- ADR-032: forwarded_for: None for wire-ingress
|
||||
- ADR-034 §4: browsers/MCP clients are not peers
|
||||
- ADR-036: /healthz raw route, stealth, error mapping (non-routing clauses survive)
|
||||
- ADR-037: MCP streamable HTTP only (no stdio)
|
||||
- ADR-039: one crate for server + client host
|
||||
- ADR-041: to_mcp 4-tool gateway
|
||||
- ADR-042: to_openapi 5-endpoint gateway
|
||||
- ADR-044: h3 deferred, WS is v1 browser path, no length prefix
|
||||
- ADR-045: to_openapi info.version semver
|
||||
- ADR-046: extra_routes custom routes
|
||||
- ADR-047: gateway is sole invoke path (no direct-call surface)
|
||||
- ADR-048: WS carries native session, not gateway shape
|
||||
|
||||
7. **What's NOT in the crate** (verify absence):
|
||||
- No `h3`/WebTransport handler (deferred per ADR-044)
|
||||
- No `from_wss` adapter (out of scope, websocket.md §"Future")
|
||||
- No stdio MCP transport (ADR-037)
|
||||
- No per-operation `POST /{service}/{op}` direct-call surface (ADR-047)
|
||||
- No traditional per-operation-paths OpenAPI projection (additive, ADR-042 §5, out of scope)
|
||||
- No env-var-based client config (ADR-014)
|
||||
|
||||
8. **Test coverage (full crate)**:
|
||||
- All unit tests pass
|
||||
- All integration tests pass
|
||||
- `cargo test -p alknet-http` (default features) succeeds
|
||||
- `cargo test -p alknet-http --features mcp` succeeds
|
||||
- `cargo test -p alknet-call` succeeds (no regressions from transport abstraction)
|
||||
|
||||
9. **Build cleanliness**:
|
||||
- `cargo fmt --check -p alknet-http` passes
|
||||
- `cargo clippy -p alknet-http` passes with no warnings
|
||||
- `cargo clippy -p alknet-http --features mcp --all-targets` passes with no warnings
|
||||
- `cargo build -p alknet-http` succeeds
|
||||
- `cargo build -p alknet-http --features mcp` succeeds
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Crate structure complete (Cargo.toml, lib.rs, all modules)
|
||||
- [ ] Feature gates correct (default h2+http1, mcp optional, h3 absent)
|
||||
- [ ] Feature gate isolation verified (MCP code not compiled without mcp)
|
||||
- [ ] All dependencies correct (alknet-core, alknet-call, axum, reqwest, hyper, rmcp)
|
||||
- [ ] No wtransport/h3 dependency (deferred per ADR-044)
|
||||
- [ ] No env-var-based config (ADR-014)
|
||||
- [ ] No-env-vars invariant holds across all handlers
|
||||
- [ ] No secret material in responses (ADR-014)
|
||||
- [ ] AccessControl is the sole gate (ADR-029 §3)
|
||||
- [ ] Internal ops → NOT_FOUND from wire (ADR-015)
|
||||
- [ ] Error fidelity (ADR-023 — HTTP_<status> prefix, no collision)
|
||||
- [ ] Browsers/MCP clients are not peers (ADR-034 §4, ADR-044 §5)
|
||||
- [ ] GatewayDispatch is a concrete struct (not a trait)
|
||||
- [ ] Auth middleware shared (HTTP routes + to_mcp)
|
||||
- [ ] SharedHttpClient is ArcSwap-wrapped
|
||||
- [ ] All ADRs conformed to (003-048, full list in checklist)
|
||||
- [ ] No h3/WebTransport handler (deferred)
|
||||
- [ ] No from_wss adapter (out of scope)
|
||||
- [ ] No stdio MCP transport (ADR-037)
|
||||
- [ ] No direct-call surface (ADR-047)
|
||||
- [ ] No traditional per-operation-paths OpenAPI (out of scope)
|
||||
- [ ] `cargo fmt --check -p alknet-http` passes
|
||||
- [ ] `cargo clippy -p alknet-http` passes with no warnings
|
||||
- [ ] `cargo clippy -p alknet-http --features mcp --all-targets` passes with no warnings
|
||||
- [ ] `cargo test -p alknet-http` passes
|
||||
- [ ] `cargo test -p alknet-http --features mcp` passes
|
||||
- [ ] `cargo test -p alknet-call` passes (no regressions)
|
||||
|
||||
## References
|
||||
|
||||
- docs/architecture/crates/http/README.md — crate index
|
||||
- docs/architecture/crates/http/overview.md — overview, dependencies, feature gates
|
||||
- docs/architecture/crates/http/http-server.md — HttpAdapter
|
||||
- docs/architecture/crates/http/http-adapters.md — from_openapi, to_openapi
|
||||
- docs/architecture/crates/http/http-mcp.md — from_mcp, to_mcp
|
||||
- docs/architecture/crates/http/websocket.md — WebSocket path
|
||||
- docs/architecture/crates/http/webtransport.md — deferred (verify absence)
|
||||
- docs/research/alknet-http-gateway-factoring/findings.md — gateway factoring
|
||||
- docs/architecture/decisions/ (all relevant ADRs: 003-048)
|
||||
|
||||
## Notes
|
||||
|
||||
> This is the final quality checkpoint. It runs after the three phase
|
||||
> reviews (review-http, review-websocket, review-mcp) and verifies the
|
||||
> crate as a whole. The key cross-cutting concerns: (1) the no-env-vars
|
||||
> invariant holds across ALL handlers (from_openapi, from_mcp), (2) the
|
||||
> GatewayDispatch shared spine is a concrete struct used by both
|
||||
> to_openapi and to_mcp, (3) the auth middleware is shared between HTTP
|
||||
> routes and the to_mcp rmcp service, (4) feature gate isolation is
|
||||
> correct (MCP code not compiled without mcp, h3 absent), (5) no
|
||||
> regressions in alknet-call from the transport abstraction. Verify the
|
||||
> absence of the deferred/out-of-scope items (h3, from_wss, stdio,
|
||||
> direct-call surface, traditional per-operation-paths OpenAPI). If
|
||||
> deviations are found, document and fix before considering the crate
|
||||
> complete.
|
||||
|
||||
## Summary
|
||||
|
||||
> To be filled on completion
|
||||
166
tasks/http/review-http.md
Normal file
166
tasks/http/review-http.md
Normal file
@@ -0,0 +1,166 @@
|
||||
---
|
||||
id: http/review-http
|
||||
name: Review alknet-http server surface + OpenAPI adapters for spec conformance
|
||||
status: pending
|
||||
depends_on: [http/server/gateway-endpoints, http/adapters/to-openapi, http/adapters/from-openapi, http/server/healthz-decoy]
|
||||
scope: broad
|
||||
risk: low
|
||||
impact: phase
|
||||
level: review
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
Review the alknet-http server surface and OpenAPI adapters for spec
|
||||
conformance, pattern consistency, and correctness. This is the quality
|
||||
checkpoint for the HTTP server + gateway + OpenAPI adapter work — the
|
||||
core of the crate.
|
||||
|
||||
### Review Checklist
|
||||
|
||||
1. **HttpAdapter conformance** (http-server.md):
|
||||
- `HttpAdapter` struct with `identity_provider`, `registry`, `decoy`, `extra_routes`
|
||||
- `DecoyConfig` enum with `NotFound` (default), `StaticSite`, `Redirect`
|
||||
- `ProtocolHandler::alpn()` returns `http/1.1` or `h2`
|
||||
- `handle()` branches on `connection.remote_alpn()` for HTTP framing
|
||||
- axum over QUIC bidirectional stream (`accept_bi` → hyper `TokioIo` → axum router)
|
||||
- Router built once at construction, cloned per connection (Arc clone)
|
||||
- `h3` ALPN not registered (deferred per ADR-044)
|
||||
- Custom routes (`extra_routes`) merged via `Router::merge` (ADR-046)
|
||||
- Default surface reserved paths take precedence on collision
|
||||
|
||||
2. **Gateway endpoints conformance** (http-server.md, http-adapters.md):
|
||||
- 5 fixed gateway endpoints (`/search`, `/schema`, `/call`, `/batch`, `/subscribe`)
|
||||
- No per-operation `POST /{service}/{op}` direct-call surface (ADR-047)
|
||||
- `/call` body is flat JSON `{ operation, input }`
|
||||
- `/call` dispatches via `GatewayDispatch::invoke` (shared spine)
|
||||
- `Internal` ops → 404 on `/call` (ADR-015)
|
||||
- `External` op + unauthorized → 403; + no identity → 401
|
||||
- `/search` dispatches `services/list` (AccessControl-filtered)
|
||||
- `/schema` dispatches `services/schema`
|
||||
- `/batch` is a loop over `invoke` (array of results in order)
|
||||
- `/subscribe` is SSE (`text/event-stream`, `call.responded` → `data:` frames)
|
||||
- `/subscribe` disconnect → `call.aborted` cascade (ADR-016)
|
||||
|
||||
3. **Error mapping conformance** (http-server.md, ADR-023):
|
||||
- `NOT_FOUND` → 404, `FORBIDDEN` → 401/403, `INVALID_INPUT` → 422, `TIMEOUT` → 504, `INTERNAL` → 500
|
||||
- Operation-level code with `http_status` → declared status
|
||||
- Operation-level code without `http_status` → 500
|
||||
- `HTTP_<status>` prefix for imported codes (no collision with protocol codes)
|
||||
- `retryable` → `Retry-After` hint for 503/429-class
|
||||
|
||||
4. **Auth conformance** (http-server.md, ADR-004):
|
||||
- Bearer-only (`Authorization: Bearer` → `resolve_from_token`)
|
||||
- Shared middleware stashes `Option<Identity>` in request extensions
|
||||
- `ResolvedIdentity` extractor reads from extensions
|
||||
- `connection.set_identity(identity)` for observability (OQ-11)
|
||||
- No `std::env::var` reads (no-env-vars invariant)
|
||||
|
||||
5. **`/healthz` and decoy conformance** (http-server.md):
|
||||
- `/healthz` is raw (no auth, no call protocol, no OperationContext)
|
||||
- `/healthz` returns 200 + "ok"
|
||||
- Decoy fallback for unknown paths
|
||||
- `DecoyConfig::NotFound` default (fake nginx 404, no alknet leak)
|
||||
- Custom routes take precedence over decoy
|
||||
|
||||
6. **`to_openapi` conformance** (http-adapters.md, ADR-042/045):
|
||||
- 5 fixed gateway endpoints in the doc (not per-operation paths)
|
||||
- `info.version` semver tracks gateway contract (initial 1.0.0)
|
||||
- Per-caller operation surface NOT preloaded (discovered via `/search`)
|
||||
- `/call` responses include protocol-level + operation-level errors
|
||||
- `HTTP_<status>`-prefixed codes projected correctly
|
||||
- Pure projection (consumes registry, does not produce entries)
|
||||
|
||||
7. **`from_openapi` conformance** (http-adapters.md, ADR-017/023):
|
||||
- `OperationAdapter` impl (`async fn import`)
|
||||
- Parse OpenAPI doc (`$ref` resolution, buildInputSchema/buildOutputSchema)
|
||||
- `operationId` (or generated name) → `spec.name`
|
||||
- `op_type` detected from method + response content type
|
||||
- `visibility` = `Internal` (ADR-015)
|
||||
- `provenance` = `FromOpenAPI`, leaf (ADR-022)
|
||||
- Error codes prefixed `HTTP_<status>` (ADR-023)
|
||||
- Forwarding handler: reqwest via `SharedHttpClient`
|
||||
- No-env-vars: reads `context.capabilities`, never `std::env::var` (ADR-014)
|
||||
- SSE parsing for `Subscription` forwarding
|
||||
- `HttpAuthScheme` (Bearer, ApiKey, Basic)
|
||||
|
||||
8. **Shared HTTP client conformance** (http-adapters.md, OQ-40):
|
||||
- `ClientWithMiddleware` (not bare `reqwest::Client`)
|
||||
- `RetryTransientMiddleware` + inlined `RetryAfterMiddleware`
|
||||
- Bounded `RetryAfterMiddleware` storage (no unbounded growth)
|
||||
- ArcSwap hot-reload (rebuild-and-swap)
|
||||
- Per-request credential injection (not at construction)
|
||||
- No env-var-based client config
|
||||
|
||||
9. **GatewayDispatch conformance** (research §5.1):
|
||||
- Concrete struct (not a trait)
|
||||
- `resolve_bearer` + `invoke` → `ResponseEnvelope`
|
||||
- Root `OperationContext`: `internal: false`, `forwarded_for: None`, fresh `request_id`
|
||||
- `handler_identity` from registration bundle
|
||||
- No `into_wire()` method (per-gateway mapping stays out)
|
||||
- No streaming abstraction (per-gateway)
|
||||
|
||||
10. **Security constraints**:
|
||||
- No secret material in HTTP response bodies (ADR-014)
|
||||
- Capabilities not serialized into responses
|
||||
- No-env-vars invariant (from_openapi reads context.capabilities)
|
||||
- Internal ops → 404 (don't leak existence)
|
||||
- AccessControl is the sole authorization gate
|
||||
|
||||
11. **Pattern consistency**:
|
||||
- GatewayDispatch is a struct, not a trait (research recommendation)
|
||||
- Auth middleware shared between HTTP routes and to_mcp (research §4.4)
|
||||
- Error mapping is a free function (not a trait method)
|
||||
- SharedHttpClient is ArcSwap-wrapped (same pattern as ConfigIdentityProvider)
|
||||
|
||||
12. **Test coverage**:
|
||||
- Unit tests for error mapping (all codes, 401/403 split)
|
||||
- Unit tests for auth middleware (valid/absent/malformed Bearer)
|
||||
- Unit tests for GatewayDispatch (invoke, services/list filtering)
|
||||
- Unit tests for to_openapi (5 paths, info.version, error projection)
|
||||
- Unit tests for from_openapi (parse, operationId, op_type, error codes)
|
||||
- Integration tests for gateway endpoints (call, search, schema, batch, subscribe)
|
||||
- Integration tests for from_openapi forwarding (no-env-vars, SSE)
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] HttpAdapter matches http-server.md (struct, DecoyConfig, ProtocolHandler, axum over QUIC)
|
||||
- [ ] Gateway endpoints match http-server.md (5 endpoints, no direct-call surface, ADR-047)
|
||||
- [ ] Error mapping matches ADR-023 (all codes, HTTP_<status> prefix, 401/403 split)
|
||||
- [ ] Auth matches ADR-004 (Bearer-only, shared middleware, set_identity)
|
||||
- [ ] /healthz is raw; decoy fallback works; custom routes take precedence
|
||||
- [ ] to_openapi matches ADR-042/045 (5 endpoints, info.version, per-caller via /search)
|
||||
- [ ] from_openapi matches http-adapters.md (OperationAdapter, no-env-vars, HTTP_<status>)
|
||||
- [ ] SharedHttpClient matches OQ-40 (ClientWithMiddleware, retry, RetryAfter, ArcSwap)
|
||||
- [ ] GatewayDispatch is a concrete struct (not a trait), shared spine correct
|
||||
- [ ] No secret material in HTTP responses (ADR-014)
|
||||
- [ ] No-env-vars invariant verified (no std::env::var in from_openapi)
|
||||
- [ ] Internal ops → 404 (don't leak existence)
|
||||
- [ ] AccessControl is the sole authorization gate
|
||||
- [ ] Test coverage adequate for all functionality
|
||||
- [ ] `cargo fmt --check -p alknet-http` passes
|
||||
- [ ] `cargo clippy -p alknet-http` passes with no warnings
|
||||
- [ ] All tests pass
|
||||
|
||||
## References
|
||||
|
||||
- docs/architecture/crates/http/README.md
|
||||
- docs/architecture/crates/http/overview.md
|
||||
- docs/architecture/crates/http/http-server.md
|
||||
- docs/architecture/crates/http/http-adapters.md
|
||||
- docs/research/alknet-http-gateway-factoring/findings.md
|
||||
- docs/architecture/decisions/ (relevant ADRs: 004, 010, 014, 015, 017, 022, 023, 036, 039, 042, 045, 046, 047)
|
||||
|
||||
## Notes
|
||||
|
||||
> This is the quality checkpoint for the HTTP server + gateway + OpenAPI
|
||||
> adapter work — the core of the crate. The review should verify that
|
||||
> the gateway is the sole invoke path (ADR-047), the error mapping is
|
||||
> faithful (ADR-023), the no-env-vars invariant holds (ADR-014), and the
|
||||
> GatewayDispatch shared spine is a concrete struct (not a trait, per
|
||||
> the research recommendation). If deviations are found, document and
|
||||
> fix before considering the server surface complete.
|
||||
|
||||
## Summary
|
||||
|
||||
> To be filled on completion
|
||||
161
tasks/http/review-mcp.md
Normal file
161
tasks/http/review-mcp.md
Normal file
@@ -0,0 +1,161 @@
|
||||
---
|
||||
id: http/review-mcp
|
||||
name: Review MCP adapters for ADR-037/041 conformance (streamable HTTP, 4-tool gateway, output handling)
|
||||
status: pending
|
||||
depends_on: [http/adapters/from-mcp, http/adapters/to-mcp]
|
||||
scope: moderate
|
||||
risk: low
|
||||
impact: phase
|
||||
level: review
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
Review the MCP adapters (`from_mcp`, `to_mcp`) for spec conformance,
|
||||
pattern consistency, and correctness. This is the quality checkpoint
|
||||
for the feature-gated MCP work.
|
||||
|
||||
### Review Checklist
|
||||
|
||||
1. **`from_mcp` conformance** (http-mcp.md):
|
||||
- `FromMCP` struct with `endpoint`, `auth_token`, `namespace`
|
||||
- `OperationAdapter` impl (`async fn import`)
|
||||
- Connects via rmcp `StreamableHttpClientTransport::from_uri(endpoint)`
|
||||
- Connection failure → `AdapterError::DiscoveryFailed`
|
||||
- 401 → `AdapterError::Unauthorized`
|
||||
- Calls `tools/list` → MCP tools
|
||||
- For each tool: `HandlerRegistration` with `FromMCP` provenance
|
||||
- `spec.name` = tool name (or `namespace/tool_name`)
|
||||
- `spec.op_type` = `Mutation` (MCP tools are call/response)
|
||||
- `spec.visibility` = `Internal` (ADR-015)
|
||||
- `spec.input_schema` = tool's `inputSchema`
|
||||
- `spec.output_schema` = declared `outputSchema` if present, else `ContentBlock` union
|
||||
- `provenance` = `FromMCP`, `composition_authority: None`, `scoped_env: None` (ADR-022)
|
||||
- `capabilities` = bearer token (injected at registration)
|
||||
|
||||
2. **`from_mcp` output handling** (http-mcp.md §"Output handling"):
|
||||
- `structured_content` present → use as result (validated against `output_schema`)
|
||||
- `structured_content` absent → map `content: Vec<ContentBlock>` to `ContentBlock` union
|
||||
- No heuristic `JSON.parse` of text blocks (carry as `ContentBlock`)
|
||||
- `isError: true` → `CallError` (not the output handling path)
|
||||
- rmcp client connection maintained for registration lifetime
|
||||
|
||||
3. **`from_mcp` no-env-vars** (ADR-014):
|
||||
- Handler reads `context.capabilities`, never `std::env::var`
|
||||
- Bearer token from `Capabilities`, not env vars
|
||||
|
||||
4. **`to_mcp` conformance** (http-mcp.md, ADR-041):
|
||||
- Implements rmcp `ServerHandler` trait (`call_tool`, `list_tools`)
|
||||
- `tools/list` returns 4 fixed gateway tools (`search`, `schema`, `call`, `batch`)
|
||||
- `tools/list` does NOT return registry's operations (discovered via `search`)
|
||||
- `search` → dispatches `services/list` via `GatewayDispatch::invoke`
|
||||
- `search` results are `AccessControl::check(identity)`-filtered
|
||||
- `Subscription` ops filtered out of `search` results (ADR-041 §2)
|
||||
- `schema` → dispatches `services/schema` via `GatewayDispatch::invoke`
|
||||
- `call` → dispatches via `GatewayDispatch::invoke` (shared spine)
|
||||
- `call` result → `CallToolResult::structured(value)` for `Ok`
|
||||
- `call` error → `CallToolResult::structured_error(details)` for `Err(CallError)`
|
||||
- `batch` → loop over `invoke`, returns array
|
||||
- `to_mcp` is a pure projection (consumes registry, does not produce entries)
|
||||
|
||||
5. **`to_mcp` auth** (http-mcp.md, research §4.4):
|
||||
- Bearer auth via shared `bearer_auth_middleware` (applied around `nest_service`)
|
||||
- `Identity` read from `RequestContext.extensions` inside `call_tool`
|
||||
- Identity survives rmcp framing (research §6 #2 — confirmed by spike)
|
||||
- MCP client has no `PeerId` (not an alknet peer, ADR-034 §4)
|
||||
- `AccessControl` gates `search` results and `call` dispatch
|
||||
|
||||
6. **`to_mcp` rmcp integration** (http-mcp.md, research §4):
|
||||
- `StreamableHttpService` nested into axum `Router` at `/mcp`
|
||||
- `ServerHandler` impl is a gateway service (4 fixed tools)
|
||||
- rmcp owns JSON-RPC framing, session management, SSE priming
|
||||
- `to_mcp` owns `call_tool`/`list_tools` dispatch + `CallToolResult` mapping
|
||||
|
||||
7. **Streamable HTTP only** (ADR-037):
|
||||
- stdio transport NOT built (not a dependency, not optional, not behind a feature)
|
||||
- `mcp` feature pulls in rmcp with streamable HTTP transport features only
|
||||
- `transport-child-process` is not a dependency
|
||||
|
||||
8. **Feature gate isolation**:
|
||||
- `from_mcp`/`to_mcp` compile only with `mcp` feature
|
||||
- `cargo check -p alknet-http` (no `mcp`) succeeds — MCP code not compiled
|
||||
- `cargo check -p alknet-http --features mcp` succeeds
|
||||
|
||||
9. **Shared dispatch spine** (research §5.1):
|
||||
- `to_mcp` `call` uses `GatewayDispatch::invoke` (same spine as `to_openapi`)
|
||||
- `ResponseEnvelope` → `CallToolResult` mapping is `to_mcp`-specific
|
||||
- No `GatewayDispatch` trait (concrete struct)
|
||||
- Identity resolution, context construction, invoke are shared (security axis)
|
||||
- Wire framing, discovery, streaming are per-gateway (presentation axis)
|
||||
|
||||
10. **Error fidelity** (ADR-023):
|
||||
- `from_mcp` error_schemas from MCP tool error descriptions
|
||||
- `to_mcp` `call` error → `CallToolResult::structured_error` with typed `details`
|
||||
- No collision with protocol-level codes
|
||||
|
||||
11. **Security constraints**:
|
||||
- No-env-vars invariant (from_mcp reads context.capabilities, ADR-014)
|
||||
- MCP clients are not peers (no PeerId, ADR-034 §4)
|
||||
- AccessControl gates search results and call dispatch
|
||||
- No secret material in CallToolResult
|
||||
|
||||
12. **Test coverage**:
|
||||
- Unit tests for from_mcp (import, outputSchema present/absent, isError)
|
||||
- Unit tests for to_mcp (tools/list returns 4, search/schema/call/batch)
|
||||
- Integration test: MCP client → search → schema → call round-trip
|
||||
- Integration test: Bearer auth gates to_mcp service
|
||||
- Integration test: no-env-vars (no std::env::var in from_mcp)
|
||||
- Feature gate: cargo check without mcp succeeds
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] from_mcp matches http-mcp.md (OperationAdapter, tools/list, outputSchema handling)
|
||||
- [ ] from_mcp output handling: structuredContent-preferred, ContentBlock fallback, no JSON.parse
|
||||
- [ ] from_mcp no-env-vars: reads context.capabilities, never std::env::var
|
||||
- [ ] to_mcp matches ADR-041 (4-tool gateway, not one tool per operation)
|
||||
- [ ] to_mcp Subscription excluded from search results
|
||||
- [ ] to_mcp uses GatewayDispatch::invoke (shared spine)
|
||||
- [ ] to_mcp CallToolResult mapping correct (structured/structured_error)
|
||||
- [ ] to_mcp auth: shared middleware, Identity survives rmcp framing
|
||||
- [ ] Streamable HTTP only (ADR-037 — stdio NOT built)
|
||||
- [ ] Feature gate isolation (mcp code not compiled without mcp feature)
|
||||
- [ ] No GatewayDispatch trait (concrete struct, research recommendation)
|
||||
- [ ] Error fidelity (ADR-023 — no collision with protocol codes)
|
||||
- [ ] MCP clients are not peers (no PeerId, ADR-034 §4)
|
||||
- [ ] AccessControl gates search and call
|
||||
- [ ] No secret material in CallToolResult
|
||||
- [ ] Test coverage adequate for all MCP functionality
|
||||
- [ ] `cargo fmt --check -p alknet-http --features mcp` passes
|
||||
- [ ] `cargo clippy -p alknet-http --features mcp` passes with no warnings
|
||||
- [ ] `cargo test -p alknet-http --features mcp` passes
|
||||
- [ ] `cargo check -p alknet-http` (no mcp) succeeds
|
||||
|
||||
## References
|
||||
|
||||
- docs/architecture/crates/http/http-mcp.md — full MCP spec
|
||||
- docs/research/alknet-http-gateway-factoring/findings.md — §4 (rmcp constraints), §4.4 (auth sharing)
|
||||
- docs/architecture/decisions/037-mcp-stdio-transport-exclusion.md — ADR-037
|
||||
- docs/architecture/decisions/041-mcp-tool-gateway-pattern.md — ADR-041
|
||||
- docs/architecture/decisions/017-call-protocol-client-and-adapter-contract.md — ADR-017 §5
|
||||
- docs/architecture/decisions/023-operation-error-schemas.md — ADR-023
|
||||
- docs/architecture/decisions/014-secret-material-flow-and-capability-injection.md — ADR-014
|
||||
- docs/architecture/decisions/034-outgoing-only-x509-and-three-peer-roles.md — ADR-034 §4
|
||||
- /workspace/rust-sdk/ — rmcp SDK (ServerHandler, StreamableHttpService, CallToolResult)
|
||||
|
||||
## Notes
|
||||
|
||||
> The MCP adapters are feature-gated and the most SDK-coupled part of
|
||||
> the crate (rmcp integration). The review should verify: (1) streamable
|
||||
> HTTP only (no stdio, ADR-037), (2) the 4-tool gateway pattern (ADR-041,
|
||||
> not one tool per operation), (3) the output handling
|
||||
> (structuredContent-preferred, ContentBlock fallback, no JSON.parse),
|
||||
> (4) the shared dispatch spine is used for to_mcp's call (same spine as
|
||||
> to_openapi), (5) the auth middleware is shared (Identity survives rmcp
|
||||
> framing — research §6 #2), (6) the no-env-vars invariant holds, (7)
|
||||
> feature gate isolation (MCP code not compiled without mcp). If
|
||||
> deviations are found, document and fix before considering the MCP
|
||||
> adapters complete.
|
||||
|
||||
## Summary
|
||||
|
||||
> To be filled on completion
|
||||
154
tasks/http/review-websocket.md
Normal file
154
tasks/http/review-websocket.md
Normal file
@@ -0,0 +1,154 @@
|
||||
---
|
||||
id: http/review-websocket
|
||||
name: Review WebSocket path for ADR-044/048 conformance (native session, no length prefix, browsers-not-peers)
|
||||
status: pending
|
||||
depends_on: [http/websocket/connection-overlay]
|
||||
scope: moderate
|
||||
risk: low
|
||||
impact: phase
|
||||
level: review
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
Review the WebSocket path for spec conformance, pattern consistency,
|
||||
and correctness. This is the quality checkpoint for the browser
|
||||
bidirectional path — the most architecturally subtle part of the crate
|
||||
(browsers are not peers, the WS path carries the native session not the
|
||||
gateway shape, no length prefix).
|
||||
|
||||
### Review Checklist
|
||||
|
||||
1. **Dispatcher transport abstraction conformance** (ADR-012):
|
||||
- `Dispatcher::dispatch_requested` is `pub` (was `pub(crate)`)
|
||||
- Abort-handling method is `pub` (extracted from `handle_stream`)
|
||||
- `CallConnection` constructible from non-QUIC source
|
||||
- Non-QUIC `CallConnection` holds overlay + pending + identity
|
||||
- Existing QUIC path (`CallAdapter`, `CallClient`) unchanged — no regressions
|
||||
- Full `alknet-call` test suite passes (no regressions in the cross-crate change)
|
||||
|
||||
2. **WS upgrade handler conformance** (websocket.md, ADR-044/048):
|
||||
- Upgrade route at `/alknet/call` (default, ADR-046 collision rule)
|
||||
- Bearer auth on upgrade request (shared `bearer_auth_middleware`)
|
||||
- No token → 401 (upgrade rejected)
|
||||
- Insufficient scopes → 403 at call time (not upgrade time)
|
||||
- Resolved identity stored on `CallConnection`
|
||||
- Upgrade works over HTTP/1.1 (RFC 6455) and HTTP/2 (RFC 8441)
|
||||
- Handler does not branch on HTTP version (WS frame stream same post-upgrade)
|
||||
|
||||
3. **Framing conformance** (websocket.md §"Framing", ADR-044 Assumption 1):
|
||||
- Binary WS message = one `EventEnvelope` (JSON serde)
|
||||
- **No length prefix** (WS message boundary is delimiter, unlike QUIC's 4-byte prefix)
|
||||
- No `FrameFramedReader`/`FrameFramedWriter` on the WS path
|
||||
- Text WS messages rejected (protocol-level close)
|
||||
- Binary payloads follow base64-as-JSON-string convention (same as QUIC)
|
||||
|
||||
4. **Dispatch conformance** (websocket.md §"Dispatch", ADR-012):
|
||||
- `call.requested` → `Dispatcher::dispatch_requested` (the pub API)
|
||||
- `AccessControl::check(identity)` gates every `call.requested`
|
||||
- `FORBIDDEN` → `call.error` (before handler runs)
|
||||
- `call.responded`/`call.completed`/`call.aborted` correlated by `id` via `PendingRequestMap`
|
||||
- Response `EventEnvelope` frames written as binary WS messages
|
||||
- `call.aborted` → the pub abort-handling method
|
||||
- No `RemoteFilter`/`remote_safe` (retired by ADR-029 §3)
|
||||
|
||||
5. **Bidirectionality conformance** (websocket.md §"Bidirectionality", ADR-043 §2):
|
||||
- Both sides can send `call.requested` (native call-protocol bidirectionality)
|
||||
- Hub can call browser-registered ops via overlay
|
||||
- Browser with no registered ops → server→client unused (use-case scoping)
|
||||
|
||||
6. **Connection-local overlay conformance** (websocket.md §"Connection-local overlay", ADR-024/034/044):
|
||||
- Browser-registered ops land in `CallConnection`'s Layer 2 overlay (not `PeerCompositeEnv`)
|
||||
- No `PeerId` for browser (no `PeerEntry`, no peer-graph membership)
|
||||
- Hub's outgoing `call.requested` routes through `overlay_env()` (not `PeerRef::Specific`)
|
||||
- `PeerRef::Specific("browser-X")` → routes to nothing (no `PeerEntry`)
|
||||
- Overlay dropped on WS close (no explicit deregistration)
|
||||
- `AccessControl` on browser ops gates hub's calls
|
||||
|
||||
7. **Browsers-are-not-peers rationale** (websocket.md §"Browsers are not alknet peers", ADR-044 §5):
|
||||
- No stable cryptographic identity (bearer token, not fingerprints)
|
||||
- Ephemeral (close tab → overlay dies)
|
||||
- Not addressable from other nodes (no `PeerEntry`)
|
||||
- "Peer" = addressable peer-graph node, not "any endpoint that exchanges calls"
|
||||
|
||||
8. **Streaming conformance** (websocket.md §"Streaming"):
|
||||
- `Subscription` over WS → `call.responded` as binary WS messages (no SSE)
|
||||
- `call.completed` closes subscription; `call.aborted` closes with error
|
||||
- WS disconnect mid-subscription → `call.aborted` cascade (ADR-016)
|
||||
|
||||
9. **ADR conformance**:
|
||||
- ADR-012: stream-agnostic correlation (Dispatcher runs unchanged)
|
||||
- ADR-016: abort cascade on disconnect
|
||||
- ADR-024: Layer 2 connection-local overlay
|
||||
- ADR-029 §3: AccessControl::check is sole gate (no remote_safe)
|
||||
- ADR-034 §4: browsers are not peers (amended by ADR-044 §5)
|
||||
- ADR-044: WS is v1 browser path, no length prefix, no h3
|
||||
- ADR-048: WS carries native session, not gateway shape
|
||||
|
||||
10. **Security constraints**:
|
||||
- AccessControl::check(identity) is the sole authorization gate
|
||||
- No secret material on the WS path (ADR-014)
|
||||
- Internal ops → NOT_FOUND (don't leak existence)
|
||||
- Abort cascade on disconnect (ADR-016)
|
||||
|
||||
11. **Test coverage**:
|
||||
- Integration test: WS upgrade → call.requested → call.responded round-trip
|
||||
- Integration test: no Bearer token → 401
|
||||
- Integration test: AccessControl denied → call.error FORBIDDEN
|
||||
- Integration test: Subscription over WS → call.responded + call.completed
|
||||
- Integration test: WS disconnect mid-subscription → call.aborted cascade
|
||||
- Integration test: text WS message → protocol close
|
||||
- Integration test: bidirectional (hub calls browser-registered op)
|
||||
- Integration test: PeerRef::Specific("browser-X") → NOT_FOUND
|
||||
- alknet-call tests pass (no regressions from the transport abstraction change)
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Dispatcher transport abstraction: pub API, non-QUIC CallConnection, no regressions
|
||||
- [ ] WS upgrade: /alknet/call, Bearer auth, 401 on no token, HTTP/1.1 + HTTP/2
|
||||
- [ ] Framing: binary WS = EventEnvelope, no length prefix, text rejected
|
||||
- [ ] Dispatch: call.requested → dispatch_requested, AccessControl gates, correlation by id
|
||||
- [ ] Bidirectionality: both sides can call.requested, hub calls browser ops via overlay
|
||||
- [ ] Connection-local overlay: no PeerId, no PeerEntry, overlay dies on close
|
||||
- [ ] Browsers-not-peers: no stable identity, ephemeral, not addressable
|
||||
- [ ] Streaming: native call.responded (no SSE), abort cascade on disconnect
|
||||
- [ ] All ADRs conformed to (012, 016, 024, 029, 034, 044, 048)
|
||||
- [ ] AccessControl is the sole authorization gate
|
||||
- [ ] No secret material on WS path
|
||||
- [ ] Internal ops → NOT_FOUND
|
||||
- [ ] Test coverage adequate for all WS functionality
|
||||
- [ ] `cargo fmt --check -p alknet-http` passes
|
||||
- [ ] `cargo clippy -p alknet-http` passes with no warnings
|
||||
- [ ] `cargo test -p alknet-call` passes (no regressions)
|
||||
- [ ] All tests pass
|
||||
|
||||
## References
|
||||
|
||||
- docs/architecture/crates/http/websocket.md — full WS spec
|
||||
- docs/architecture/decisions/044-defer-webtransport-browsers-use-websocket.md — ADR-044
|
||||
- docs/architecture/decisions/048-websocket-native-session-not-gateway.md — ADR-048
|
||||
- docs/architecture/decisions/012-call-protocol-stream-model.md — ADR-012
|
||||
- docs/architecture/decisions/016-abort-cascade-for-nested-calls.md — ADR-016
|
||||
- docs/architecture/decisions/024-operation-registry-layering.md — ADR-024
|
||||
- docs/architecture/decisions/029-peer-graph-routing-model.md — ADR-029 §3
|
||||
- docs/architecture/decisions/034-outgoing-only-x509-and-three-peer-roles.md — ADR-034 §4
|
||||
- /workspace/@alkdev/pubsub/src/event-target-websocket-client.ts — prior art
|
||||
|
||||
## Notes
|
||||
|
||||
> The WebSocket path is the most architecturally subtle part of the
|
||||
> crate. The review should verify: (1) the Dispatcher transport
|
||||
> abstraction didn't regress the QUIC path (run alknet-call tests), (2)
|
||||
> the WS path carries the native EventEnvelope session not the gateway
|
||||
> shape (ADR-048), (3) no length prefix (ADR-044 Assumption 1), (4)
|
||||
> browsers are not peers (no PeerId, connection-local overlay, ADR-034 §4
|
||||
> + ADR-044 §5), (5) AccessControl is the sole gate (ADR-029 §3), (6)
|
||||
> abort cascade on disconnect (ADR-016). The "browsers are not peers"
|
||||
> rationale is load-bearing — verify the three grounds (no stable
|
||||
> identity, ephemeral, not addressable) are reflected in the
|
||||
> implementation. If deviations are found, document and fix before
|
||||
> considering the WS path complete.
|
||||
|
||||
## Summary
|
||||
|
||||
> To be filled on completion
|
||||
179
tasks/http/server/bearer-auth-middleware.md
Normal file
179
tasks/http/server/bearer-auth-middleware.md
Normal 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
|
||||
194
tasks/http/server/gateway-endpoints.md
Normal file
194
tasks/http/server/gateway-endpoints.md
Normal 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
|
||||
146
tasks/http/server/healthz-decoy.md
Normal file
146
tasks/http/server/healthz-decoy.md
Normal 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
|
||||
217
tasks/http/server/http-adapter.md
Normal file
217
tasks/http/server/http-adapter.md
Normal 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
|
||||
182
tasks/http/websocket/connection-overlay.md
Normal file
182
tasks/http/websocket/connection-overlay.md
Normal file
@@ -0,0 +1,182 @@
|
||||
---
|
||||
id: http/websocket/connection-overlay
|
||||
name: Implement connection-local Layer 2 overlay for browser-registered ops (no PeerId, ADR-024/034/044)
|
||||
status: pending
|
||||
depends_on: [http/websocket/upgrade-handler]
|
||||
scope: moderate
|
||||
risk: medium
|
||||
impact: component
|
||||
level: implementation
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
Implement the connection-local Layer 2 overlay for browser-registered
|
||||
ops in `src/websocket/overlay.rs`. This is the mechanism that gives a
|
||||
browser bidirectional-call capability *without* peer-graph membership
|
||||
(ADR-024, ADR-034 §4, ADR-044 §5). A browser over WebSocket has no
|
||||
`PeerId`, does not enter `PeerCompositeEnv`, and any ops it registers
|
||||
land in a per-`CallConnection` overlay that dies when the connection
|
||||
drops.
|
||||
|
||||
### Browsers are not alknet peers (websocket.md §"Browsers are not alknet peers")
|
||||
|
||||
A browser over WebSocket authenticates by bearer token, gets no
|
||||
`PeerId`, does not enter `PeerCompositeEnv`, and its registered ops (if
|
||||
any) land in the connection-local Layer 2 overlay. The rationale, stated
|
||||
in ADR-044 §5 and amending ADR-034 §4 by reference, is a load-bearing
|
||||
distinction:
|
||||
|
||||
**"Peer" in alknet means an addressable node in the call-protocol peer
|
||||
graph** — a stable `PeerId`, reachable via `PeerRef::Specific`, whose ops
|
||||
land in `PeerCompositeEnv`, whose identity is stable across reconnects.
|
||||
It does *not* mean "any endpoint that exchanges calls during a live
|
||||
session." A browser is the second thing but not the first, on three
|
||||
concrete grounds:
|
||||
|
||||
1. **No stable cryptographic identity of its own.** A `PeerEntry` is
|
||||
anchored to fingerprints (Ed25519, X.509) that *the peer* presents
|
||||
and the local node pins. A browser presents a bearer token the *hub*
|
||||
issued; the "identity" is the hub's bookkeeping for that token, not
|
||||
something the browser owns or that could be pinned by another node.
|
||||
There is nothing to put in `PeerEntry.fingerprints`.
|
||||
|
||||
2. **Ephemeral.** Close the tab → connection dies → the connection-local
|
||||
Layer 2 overlay dies with it. A `PeerEntry` keyed to a browser would
|
||||
be a permanently-dead entry within seconds. `PeerRef::Specific("browser-X")`
|
||||
from another node would route to nothing.
|
||||
|
||||
3. **Not addressable from other nodes.** `PeerRef::Specific` resolves
|
||||
through `PeerEntry` → `PeerId`. Another alknet node has no way to
|
||||
reach "the browser currently connected to hub-A"; the hub holds that
|
||||
connection as a live `CallConnection` handle, not as a peer-graph
|
||||
entry. The connection-local overlay is precisely the mechanism that
|
||||
gives the browser bidirectional-call capability *without* peer-graph
|
||||
membership.
|
||||
|
||||
### The overlay (websocket.md §"Connection-local overlay")
|
||||
|
||||
A browser over WebSocket has no `PeerId` on the hub's side. Any ops the
|
||||
browser registers land in a **connection-local Layer 2 overlay**
|
||||
(ADR-024) — a per-`CallConnection` overlay that dies when the connection
|
||||
drops. This is the same mechanism ADR-034 §2 describes for the inbound
|
||||
browser case: the browser is a bidirectional call target during a live
|
||||
session, not a peer-graph member, and the connection-local overlay is
|
||||
what gives it bidirectional-call capability *without* peer-graph
|
||||
membership.
|
||||
|
||||
When the WS connection closes (browser closes the tab, network drops),
|
||||
the overlay and all its registered ops are dropped — no explicit
|
||||
deregistration needed. A `PeerRef::Specific("browser-X")` from another
|
||||
node would route to nothing, because there is no `PeerEntry` for the
|
||||
browser.
|
||||
|
||||
### Bidirectionality (websocket.md §"Bidirectionality")
|
||||
|
||||
The WS call-protocol session inherits the call protocol's native
|
||||
bidirectionality: both sides can send `call.requested` frames. The
|
||||
browser calls operations on the hub; the hub can call operations
|
||||
registered on the browser's side, over the same session, using the same
|
||||
`PendingRequestMap` and `EventEnvelope` framing as `alknet/call`.
|
||||
|
||||
The browser case where the client registers no operations of its own
|
||||
is the common case — the server→client call direction is unused because
|
||||
the browser has nothing to call. That is a use-case scoping, not an
|
||||
architectural limitation. A browser that *does* expose ops (e.g., a UI
|
||||
that registers a `ui/dragged` op the hub can call to push live updates)
|
||||
registers them in the connection-local Layer 2 overlay, and the hub
|
||||
reaches them through the live `CallConnection` handle — not through
|
||||
`PeerRef::Specific` (the browser is not a peer).
|
||||
|
||||
### Implementation
|
||||
|
||||
The `CallConnection` constructed by the upgrade handler (the
|
||||
`upgrade-handler` task, via the `dispatcher-transport-abstraction`
|
||||
task's non-QUIC constructor) already holds a Layer 2 overlay
|
||||
(`imported_operations: Arc<RwLock<HashMap<String, HandlerRegistration>>>`)
|
||||
and exposes `register_imported()` / `register_imported_all()` /
|
||||
`overlay_env()`. The browser registers ops via these methods; the
|
||||
overlay is per-connection and dies when the `CallConnection` is dropped
|
||||
(WS close).
|
||||
|
||||
This task ensures:
|
||||
|
||||
1. The overlay is correctly scoped to the WS connection (not the
|
||||
`PeerCompositeEnv` — no `PeerId`, no `PeerEntry`).
|
||||
2. The hub's outgoing `call.requested` to browser-registered ops routes
|
||||
through the `CallConnection`'s overlay (via `overlay_env()`), not
|
||||
through `PeerRef::Specific`.
|
||||
3. The overlay is dropped on WS close (no explicit deregistration; the
|
||||
`Arc<RwLock<HashMap>>` is dropped when the `CallConnection` is
|
||||
dropped).
|
||||
4. `AccessControl::check(identity)` gates the hub's calls to
|
||||
browser-registered ops (the browser's bearer-token identity is the
|
||||
caller identity for the hub's outgoing calls — wait, no: the *hub*
|
||||
is the caller when it calls a browser op; the browser's identity is
|
||||
the *handler* identity. Clarify: the hub's `call.requested` to a
|
||||
browser op runs with the hub's identity as caller, the browser's
|
||||
registration bundle's `composition_authority` as handler identity.
|
||||
The browser's `AccessControl` on its registered ops gates whether
|
||||
the hub is allowed to call them.)
|
||||
5. Abort cascade on WS disconnect (ADR-016): when the WS connection
|
||||
closes, all in-flight subscriptions and calls to browser ops are
|
||||
aborted, cascading to descendants.
|
||||
|
||||
### What this task does NOT do
|
||||
|
||||
- **No `PeerEntry` for the browser.** The browser is not in the peer
|
||||
graph. This task ensures the overlay is connection-local, not
|
||||
peer-graph.
|
||||
- **No `from_wss` adapter.** Out of scope (websocket.md §"Future" —
|
||||
scope decision). This task is about the browser *registering* ops on
|
||||
its connection, not about importing a remote node's ops over WS.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Browser-registered ops land in the `CallConnection`'s Layer 2 overlay (not `PeerCompositeEnv`)
|
||||
- [ ] No `PeerId` created for the browser (no `PeerEntry`, no peer-graph membership)
|
||||
- [ ] `register_imported()` / `register_imported_all()` work for browser ops
|
||||
- [ ] Hub's outgoing `call.requested` to browser ops routes through `overlay_env()`
|
||||
- [ ] Hub's outgoing calls do NOT route through `PeerRef::Specific` (browser is not a peer)
|
||||
- [ ] `AccessControl` on browser-registered ops gates the hub's calls
|
||||
- [ ] Overlay dropped on WS close (no explicit deregistration; `Arc<RwLock<HashMap>>` dropped)
|
||||
- [ ] `PeerRef::Specific("browser-X")` from another node → routes to nothing (no `PeerEntry`)
|
||||
- [ ] WS close → all in-flight subscriptions/calls to browser ops aborted (ADR-016 cascade)
|
||||
- [ ] WS close → overlay and all registered ops dropped
|
||||
- [ ] Bidirectionality: hub can `call.requested` to browser-registered ops
|
||||
- [ ] Browser with no registered ops → server→client direction unused (use-case scoping, not a limitation)
|
||||
- [ ] Integration test: browser registers op → hub calls it via overlay
|
||||
- [ ] Integration test: WS close → overlay dropped (op no longer reachable)
|
||||
- [ ] Integration test: `PeerRef::Specific("browser-X")` → NOT_FOUND (no PeerEntry)
|
||||
- [ ] Integration test: WS close mid-call to browser op → `call.aborted` cascade
|
||||
- [ ] Integration test: `AccessControl` on browser op gates hub's call
|
||||
- [ ] `cargo test -p alknet-http` succeeds
|
||||
- [ ] `cargo clippy -p alknet-http --all-targets` succeeds with no warnings
|
||||
|
||||
## References
|
||||
|
||||
- docs/architecture/crates/http/websocket.md — Connection-local overlay (§"Connection-local overlay"), Bidirectionality (§"Bidirectionality"), Browsers are not peers (§"Browsers are not alknet peers")
|
||||
- docs/architecture/decisions/024-operation-registry-layering.md — ADR-024 (Layer 2 connection-local overlay)
|
||||
- docs/architecture/decisions/034-outgoing-only-x509-and-three-peer-roles.md — ADR-034 §4 (browsers are not peers)
|
||||
- docs/architecture/decisions/044-defer-webtransport-browsers-use-websocket.md — ADR-044 §5 (addressability vs bidirectionality rationale)
|
||||
- docs/architecture/decisions/016-abort-cascade-for-nested-calls.md — ADR-016 (abort cascade on disconnect)
|
||||
- docs/architecture/decisions/029-peer-graph-routing-model.md — ADR-029 (PeerRef::Specific routes through PeerEntry → PeerId)
|
||||
|
||||
## Notes
|
||||
|
||||
> The connection-local overlay is the mechanism that gives a browser
|
||||
> bidirectional-call capability without peer-graph membership. The
|
||||
> browser has no PeerId, no PeerEntry, no PeerCompositeEnv entry — it is
|
||||
> a bidirectional call target during a live session, not a peer-graph
|
||||
> member. The overlay dies with the WS connection (no explicit
|
||||
> deregistration). The hub reaches browser ops through the live
|
||||
> CallConnection handle's overlay_env(), not through PeerRef::Specific.
|
||||
> The "browsers are not peers" rationale (ADR-044 §5) is load-bearing:
|
||||
> "peer" means addressable peer-graph node, not "any endpoint that
|
||||
> exchanges calls during a live session." A browser has no stable
|
||||
> cryptographic identity, is ephemeral, and is not addressable from
|
||||
> other nodes — three concrete grounds for not being a peer.
|
||||
|
||||
## Summary
|
||||
|
||||
> To be filled on completion
|
||||
180
tasks/http/websocket/dispatcher-transport-abstraction.md
Normal file
180
tasks/http/websocket/dispatcher-transport-abstraction.md
Normal file
@@ -0,0 +1,180 @@
|
||||
---
|
||||
id: http/websocket/dispatcher-transport-abstraction
|
||||
name: Expose EventEnvelope-level dispatch API in alknet-call for non-QUIC transports (WebSocket)
|
||||
status: pending
|
||||
depends_on: [http/crate-init]
|
||||
scope: moderate
|
||||
risk: high
|
||||
impact: project
|
||||
level: implementation
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
Expose an `EventEnvelope`-level dispatch API in `alknet-call` so the
|
||||
WebSocket handler can feed deserialized envelopes directly to the shared
|
||||
`Dispatcher`, without requiring a QUIC `Connection`. This is a
|
||||
**cross-crate task** (modifies `alknet-call`) and the **highest-risk
|
||||
task** in the http phase: the spec says "the `Dispatcher` runs unchanged"
|
||||
over WS (ADR-012, ADR-048), but the current implementation is
|
||||
QUIC-specific in two places that need loosening.
|
||||
|
||||
### The problem
|
||||
|
||||
The current `Dispatcher` (in `crates/alknet-call/src/protocol/dispatch.rs`)
|
||||
is transport-agnostic in *intent* (ADR-012 — stream-agnostic
|
||||
correlation) but QUIC-specific in *two* integration points:
|
||||
|
||||
1. **`Dispatcher::handle_stream`** takes raw `SendStream` / `RecvStream`
|
||||
(QUIC-backed `alknet_core::types::SendStream` / `RecvStream`) and uses
|
||||
`FrameFramedReader` (4-byte length-prefixed framing). The WebSocket
|
||||
path does NOT use length-prefix framing — a WS binary message is
|
||||
already length-delimited by the WS frame boundary (ADR-044 Assumption
|
||||
1). The WS handler deserializes `EventEnvelope` from each binary WS
|
||||
message directly (no `FrameFramedReader`), and needs to feed the
|
||||
envelope to the dispatch logic.
|
||||
|
||||
2. **`CallConnection`** wraps an `alknet_core::types::Connection` (which
|
||||
wraps a QUIC `quinn::Connection` or `iroh::endpoint::Connection`).
|
||||
The WS path has no QUIC connection — it has a WS message stream. The
|
||||
`CallConnection` is needed for: the Layer 2 overlay
|
||||
(`imported_operations`), the `PendingRequestMap` (correlation), and
|
||||
the `connection.identity()` (the resolved bearer identity). The WS
|
||||
path needs a `CallConnection`-equivalent that holds these without a
|
||||
QUIC `Connection`.
|
||||
|
||||
### The fix: expose `dispatch_requested` as `pub`
|
||||
|
||||
The core dispatch logic — `Dispatcher::dispatch_requested` — is already
|
||||
transport-agnostic: it takes a `request_id: String`, a `payload: Value`
|
||||
(the `EventEnvelope` payload), and a `&Arc<CallConnection>`, and returns
|
||||
a `ResponseEnvelope`. It is currently `pub(crate)`. **Expose it as
|
||||
`pub`** so the WS handler can call it directly with a deserialized
|
||||
`EventEnvelope` payload.
|
||||
|
||||
Similarly, the abort-cascade handling (`call.aborted` events) is in
|
||||
`Dispatcher::handle_stream` — extract the abort-handling logic into a
|
||||
`pub` method so the WS handler can call it for `call.aborted` events.
|
||||
|
||||
### The fix: `CallConnection` from a non-QUIC transport
|
||||
|
||||
The `CallConnection` needs to be constructible from a non-QUIC source.
|
||||
Two options (pick the cleaner one during implementation):
|
||||
|
||||
**Option A: A `CallConnection::new_overlay_only(identity)` constructor.**
|
||||
Construct a `CallConnection` that holds the Layer 2 overlay +
|
||||
`PendingRequestMap` + the resolved bearer `Identity`, but no QUIC
|
||||
`Connection`. The `connection()` accessor returns a stub or the
|
||||
`identity()` is stored directly. This is the minimal change —
|
||||
`CallConnection` gains a constructor that doesn't require a QUIC
|
||||
`Connection`, and the `identity()` is read from a stored field rather
|
||||
than `connection.identity()`.
|
||||
|
||||
**Option B: Extract a `CallSession` trait.** Define a trait that
|
||||
`CallConnection` and a new `WsCallSession` both implement, with
|
||||
`identity()`, `overlay_env()`, `pending()`, `register_imported()`. The
|
||||
`Dispatcher` takes `&Arc<dyn CallSession>`. This is more invasive but
|
||||
cleaner; it's the right choice if the QUIC/WS divergence is large.
|
||||
|
||||
**Recommendation: Option A** unless the divergence is larger than it
|
||||
appears. The `CallConnection` already holds the overlay + pending as
|
||||
`Arc<RwLock<...>>` / `Arc<Mutex<...>>` (independent of the QUIC
|
||||
`Connection`); the only QUIC-coupled piece is the `connection: Arc<Connection>`
|
||||
field and the `connection.identity()` call. A constructor that stores
|
||||
the `Identity` directly (and returns `None` from `connection()` or
|
||||
provides a `identity()` accessor that reads the stored field) is the
|
||||
minimal change.
|
||||
|
||||
### The WS dispatch loop (how the WS handler uses this)
|
||||
|
||||
The WS upgrade handler (the `websocket/upgrade-handler` task) will:
|
||||
|
||||
1. Resolve the bearer identity at upgrade time.
|
||||
2. Construct a `CallConnection` (via the new constructor — Option A) or
|
||||
equivalent (Option B) holding the identity, a fresh Layer 2 overlay,
|
||||
and a fresh `PendingRequestMap`.
|
||||
3. Construct a `Dispatcher` (already `pub`).
|
||||
4. For each binary WS message: deserialize `EventEnvelope`, match on
|
||||
`envelope.r#type`:
|
||||
- `call.requested` → call `Dispatcher::dispatch_requested(connection,
|
||||
request_id, payload)` (now `pub`), get `ResponseEnvelope`, convert
|
||||
to `EventEnvelope`, write back as binary WS message.
|
||||
- `call.aborted` → call the extracted `pub` abort-handling method.
|
||||
- `call.responded` / `call.completed` → correlate via
|
||||
`PendingRequestMap` (the WS handler's outgoing calls —
|
||||
bidirectionality, ADR-043 §2).
|
||||
5. On WS close: fail all pending, drop the overlay (connection-local,
|
||||
dies with the WS connection).
|
||||
|
||||
### What this task does NOT do
|
||||
|
||||
- **No WS upgrade handler.** The upgrade handler is the
|
||||
`websocket/upgrade-handler` task. This task exposes the API it calls.
|
||||
- **No WS framing.** The WS message → `EventEnvelope` deserialization is
|
||||
the `websocket/upgrade-handler` task. This task takes deserialized
|
||||
envelopes.
|
||||
- **No `from_wss` adapter.** Out of scope (websocket.md §"Future" —
|
||||
scope decision, not a two-way-door deferral).
|
||||
|
||||
### Why this is the highest-risk task
|
||||
|
||||
This task modifies `alknet-call`'s security-relevant dispatch code. The
|
||||
`dispatch_requested` method runs `AccessControl::check(identity)` — the
|
||||
sole authorization gate (ADR-029 §3). Exposing it as `pub` is safe (the
|
||||
WS handler is in `alknet-http`, a trusted crate), but the change must
|
||||
not alter the dispatch logic itself. The `CallConnection` change must
|
||||
not break the existing QUIC path (the `CallAdapter` and `CallClient`
|
||||
construct `CallConnection` from a QUIC `Connection` — that path must
|
||||
continue to work unchanged). Run the full `alknet-call` test suite after
|
||||
the change.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] `Dispatcher::dispatch_requested` is `pub` (was `pub(crate)`)
|
||||
- [ ] Abort-cascade handling extracted to a `pub` method (was inline in `handle_stream`)
|
||||
- [ ] `CallConnection` constructible from a non-QUIC source (Option A or B)
|
||||
- [ ] New `CallConnection` constructor stores `Identity` directly (or equivalent)
|
||||
- [ ] `CallConnection::identity()` works for the non-QUIC case
|
||||
- [ ] `CallConnection::overlay_env()`, `pending()`, `register_imported()` work for non-QUIC
|
||||
- [ ] Existing QUIC path (`CallAdapter`, `CallClient`) unchanged — no regressions
|
||||
- [ ] `Dispatcher::handle_stream` (QUIC path) still works unchanged
|
||||
- [ ] `Dispatcher::run_loop` (QUIC path) still works unchanged
|
||||
- [ ] `cargo test -p alknet-call` — all existing tests pass (no regressions)
|
||||
- [ ] `cargo clippy -p alknet-call --all-targets` — no warnings
|
||||
- [ ] Unit test: `dispatch_requested` callable with a non-QUIC `CallConnection`
|
||||
- [ ] Unit test: abort-handling method callable with a non-QUIC `CallConnection`
|
||||
- [ ] Unit test: `CallConnection` from non-QUIC source holds overlay + pending + identity
|
||||
- [ ] Integration test: dispatch a `call.requested` via the `pub` API → `ResponseEnvelope`
|
||||
- [ ] Integration test: abort cascade via the `pub` API
|
||||
- [ ] `cargo test -p alknet-http` succeeds (the WS handler can use the API)
|
||||
- [ ] `cargo clippy -p alknet-http --all-targets` succeeds with no warnings
|
||||
|
||||
## References
|
||||
|
||||
- docs/architecture/crates/http/websocket.md — Dispatch (§"Dispatch: the shared Dispatcher, unchanged"), Framing (§"Framing")
|
||||
- docs/architecture/decisions/012-call-protocol-stream-model.md — ADR-012 (stream-agnostic correlation)
|
||||
- docs/architecture/decisions/048-websocket-native-session-not-gateway.md — ADR-048 (WS carries native session)
|
||||
- docs/architecture/decisions/044-defer-webtransport-browsers-use-websocket.md — ADR-044 (WS message boundary is delimiter, no length prefix)
|
||||
- docs/architecture/crates/call/call-protocol.md — Dispatcher, EventEnvelope wire format
|
||||
- docs/architecture/crates/call/client-and-adapters.md — Shared Dispatcher (§"Shared Dispatcher")
|
||||
- crates/alknet-call/src/protocol/dispatch.rs — current Dispatcher implementation
|
||||
- crates/alknet-call/src/protocol/connection.rs — current CallConnection implementation
|
||||
|
||||
## Notes
|
||||
|
||||
> This is the highest-risk task in the http phase. It modifies
|
||||
> alknet-call's security-relevant dispatch code to expose an
|
||||
> EventEnvelope-level API for non-QUIC transports. The spec says "the
|
||||
> Dispatcher runs unchanged" (ADR-012), but the current implementation is
|
||||
> QUIC-specific in two places: handle_stream takes raw SendStream/RecvStream
|
||||
> (length-prefixed framing), and CallConnection wraps a QUIC Connection.
|
||||
> The fix is to expose dispatch_requested as pub and make CallConnection
|
||||
> constructible from a non-QUIC source. The existing QUIC path (CallAdapter,
|
||||
> CallClient) must not regress — run the full alknet-call test suite. The
|
||||
> WS handler (websocket/upgrade-handler task) is the consumer of this API.
|
||||
> This task is tracked in tasks/http/ because it unblocks the WS path, but
|
||||
> it modifies alknet-call — coordinate with the call crate's conventions.
|
||||
|
||||
## Summary
|
||||
|
||||
> To be filled on completion
|
||||
230
tasks/http/websocket/upgrade-handler.md
Normal file
230
tasks/http/websocket/upgrade-handler.md
Normal file
@@ -0,0 +1,230 @@
|
||||
---
|
||||
id: http/websocket/upgrade-handler
|
||||
name: Implement WebSocket upgrade handler (native EventEnvelope session, no length prefix, bearer auth)
|
||||
status: pending
|
||||
depends_on: [http/server/http-adapter, http/websocket/dispatcher-transport-abstraction, http/server/bearer-auth-middleware]
|
||||
scope: broad
|
||||
risk: high
|
||||
impact: component
|
||||
level: implementation
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
Implement the WebSocket upgrade handler in `src/websocket/upgrade.rs`.
|
||||
This is the v1 browser bidirectional path (ADR-044): a browser (or any
|
||||
WS client) upgrades an HTTP/1.1 or HTTP/2 request to WebSocket and
|
||||
speaks the call protocol over binary WS messages — full-duplex, both
|
||||
sides can initiate calls (the call protocol's native bidirectionality,
|
||||
ADR-012). The WS path carries the **native `EventEnvelope` session, not
|
||||
the HTTP gateway shape** (ADR-048): the gateway endpoints
|
||||
(`/search`/`/schema`/`/call`/`/batch`/`/subscribe`) are HTTP-only and do
|
||||
not appear on WS; discovery is via `services/list`/`services/schema` as
|
||||
ordinary call-protocol ops.
|
||||
|
||||
### The upgrade handler (websocket.md §"The WS upgrade handler")
|
||||
|
||||
The WS upgrade is an HTTP/1.1 or HTTP/2 request handled by an axum route
|
||||
on `HttpAdapter`'s router. The handler:
|
||||
|
||||
1. Receives the HTTP upgrade request (axum's `WebSocketUpgrade` extractor).
|
||||
2. Resolves the caller's identity from the `Authorization: Bearer` header
|
||||
via `identity_provider.resolve_from_token(&AuthToken { raw:
|
||||
token_bytes })` (the shared `bearer_auth_middleware` — same auth path
|
||||
as any HTTP request). The upgrade is rejected (`401`) if no token is
|
||||
present; insufficient scopes for any op the browser later calls
|
||||
surface as `403`/`FORBIDDEN` at call time, not at upgrade time (the
|
||||
upgrade doesn't know which ops the browser will call).
|
||||
3. Upgrades to WebSocket (axum's `WebSocketUpgrade::on_upgrade`),
|
||||
producing a full-duplex `WebSocket` stream.
|
||||
4. Wraps the `WebSocket` stream as a `BiStream`-satisfying transport — a
|
||||
WS binary message in either direction is one `EventEnvelope` frame.
|
||||
5. Constructs a `Dispatcher` (the shared dispatch loop) with the
|
||||
`Arc<OperationRegistry>` and `Arc<dyn IdentityProvider>` the
|
||||
`HttpAdapter` holds, plus a connection-local Layer 2 overlay for any
|
||||
ops the browser registers (the `connection-overlay` task).
|
||||
6. Spawns the dispatch task on a tokio task; the WS connection is live
|
||||
until either side closes it or the browser drops the handle (closes
|
||||
the tab).
|
||||
|
||||
### The upgrade path
|
||||
|
||||
The **default upgrade path is `/alknet/call`** (the deployment may
|
||||
override it via the `extra_routes` mechanism of ADR-046, but a
|
||||
deployment that passes no custom routes gets `/alknet/call`). The path
|
||||
must not collide with the reserved gateway/`/healthz`/`/openapi.json`/
|
||||
MCP/custom-route paths per ADR-046's collision rule; `/alknet/call`
|
||||
namespaces away from the reserved set naturally. A deployment that
|
||||
builds a custom REST projection with `POST /{service}/{op}` routes
|
||||
(ADR-047 §4) coexists with the WS upgrade at `/alknet/call` — axum's
|
||||
`Router::merge` prioritizes specific routes over wildcards, so the WS
|
||||
upgrade's exact `/alknet/call` path wins over any `/{service}/{op}`
|
||||
wildcard.
|
||||
|
||||
The upgrade runs over HTTP/1.1 (the standard `Upgrade: websocket` header,
|
||||
RFC 6455) or HTTP/2 (the extended CONNECT protocol, RFC 8441);
|
||||
axum/hyper supports both, and the handler does not branch on which —
|
||||
the WS frame stream is the same once the upgrade completes.
|
||||
|
||||
### Framing: `EventEnvelope` over binary WS messages (websocket.md §"Framing")
|
||||
|
||||
Every message on the WS connection is a binary WebSocket message
|
||||
containing one `EventEnvelope`:
|
||||
|
||||
```rust
|
||||
pub struct EventEnvelope {
|
||||
pub r#type: String, // "call.requested" | "call.responded" | "call.completed" | "call.aborted" | "call.error"
|
||||
pub id: String, // Correlation key (request ID, subscription ID)
|
||||
pub payload: Value, // serde_json::Value — schema depends on event type
|
||||
}
|
||||
```
|
||||
|
||||
This is the call protocol's wire format verbatim. **The WS path carries
|
||||
no length prefix**: one `EventEnvelope` JSON object = one binary WS
|
||||
message, and the WS message boundary is the delimiter. The
|
||||
implementation must not prepend the QUIC length prefix on outbound WS
|
||||
messages or expect it on inbound ones — the two framings are
|
||||
deliberately different, matching each transport's native boundary
|
||||
semantics. (The `FrameFramedReader`/`FrameFramedWriter` types the QUIC
|
||||
dispatch loop uses are replaced on the WS path by direct JSON serde
|
||||
over the WS message type; the `Dispatcher` itself is transport-agnostic
|
||||
and consumes `EventEnvelope` values, not raw bytes.)
|
||||
|
||||
Binary payloads within `EventEnvelope.payload` follow the same
|
||||
base64-as-JSON-string convention the QUIC path uses — the envelope
|
||||
carries `serde_json::Value` and does not interpret binary fields; that's
|
||||
a handler-level concern, transport-agnostic.
|
||||
|
||||
Text WS messages are not used; all call-protocol frames are binary. A
|
||||
client that sends a text message gets a protocol-level close (the WS
|
||||
handler validates message type).
|
||||
|
||||
### Dispatch: the shared `Dispatcher` (websocket.md §"Dispatch")
|
||||
|
||||
The WS message stream is handed to the `Dispatcher` — the same dispatch
|
||||
loop the `CallAdapter` uses for `alknet/call` QUIC connections. The
|
||||
dispatch half is one implementation; the connection-establishment half
|
||||
differs (WS upgrade handler vs QUIC accept/dial), but after
|
||||
establishment the `Dispatcher` runs identically:
|
||||
|
||||
- Reads `EventEnvelope` frames from the WS message stream (deserialized
|
||||
from binary WS messages — no `FrameFramedReader`).
|
||||
- For `call.requested`: resolves the peer's identity (the bearer-token
|
||||
identity resolved at upgrade time, stored on the connection), runs
|
||||
`AccessControl::check(identity)` against the op's `AccessControl`,
|
||||
dispatches via `OperationRegistry::invoke()` if allowed, returns
|
||||
`FORBIDDEN` (→ `call.error`) before the handler runs if not.
|
||||
- For `call.responded`/`call.completed`/`call.aborted`: correlates by
|
||||
`id` via `PendingRequestMap` (keyed by request ID, not by transport —
|
||||
ADR-012).
|
||||
- Writes response `EventEnvelope` frames back as binary WS messages.
|
||||
|
||||
Peer authorization flows through the existing `AccessControl::check`
|
||||
against the resolved identity — no `RemoteFilter`, no `remote_safe`
|
||||
gate (retired by ADR-029 §3).
|
||||
|
||||
### Using the exposed dispatch API
|
||||
|
||||
This task uses the `pub` dispatch API exposed by the
|
||||
`dispatcher-transport-abstraction` task:
|
||||
|
||||
- `Dispatcher::dispatch_requested(connection, request_id, payload)` —
|
||||
for `call.requested` events.
|
||||
- The `pub` abort-handling method — for `call.aborted` events.
|
||||
- `CallConnection` constructed from the non-QUIC source (holding the
|
||||
resolved bearer identity, a fresh Layer 2 overlay, a fresh
|
||||
`PendingRequestMap`).
|
||||
|
||||
### Bidirectionality (websocket.md §"Bidirectionality")
|
||||
|
||||
The WS call-protocol session inherits the call protocol's native
|
||||
bidirectionality: both sides can send `call.requested` frames. The
|
||||
browser calls operations on the hub; the hub can call operations
|
||||
registered on the browser's side, over the same session, using the same
|
||||
`PendingRequestMap` and `EventEnvelope` framing as `alknet/call`.
|
||||
|
||||
The browser case where the client registers no operations of its own
|
||||
is the common case — the server→client call direction is unused
|
||||
because the browser has nothing to call. That is a use-case scoping,
|
||||
not an architectural limitation. A browser that *does* expose ops
|
||||
registers them in the connection-local Layer 2 overlay (the
|
||||
`connection-overlay` task).
|
||||
|
||||
### Streaming: native `call.responded` events, no SSE (websocket.md §"Streaming")
|
||||
|
||||
A `Subscription` operation invoked over WS streams `call.responded`
|
||||
events as binary WS messages directly — **no SSE `data:` framing**. SSE
|
||||
is the `h2`/`http/1.1` streaming projection; on WS it is unnecessary
|
||||
because WS is already a framed full-duplex channel. The browser receives
|
||||
`call.responded` events one per WS binary message, with the same `id`
|
||||
correlating them to the original `call.requested`; `call.completed`
|
||||
closes the subscription; `call.aborted` closes it with an error frame.
|
||||
|
||||
On WS client disconnect (the browser closes the tab mid-subscription),
|
||||
the WS handler detects the stream close and sends `call.aborted` for
|
||||
the in-flight subscription, which cascades to descendants per ADR-016.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] WS upgrade route at `/alknet/call` (default, ADR-046 collision rule)
|
||||
- [ ] Upgrade handler uses axum's `WebSocketUpgrade` extractor
|
||||
- [ ] Bearer auth on upgrade request via shared `bearer_auth_middleware`
|
||||
- [ ] No token → `401` (upgrade rejected)
|
||||
- [ ] Token present but insufficient scopes → `403` at call time (not upgrade time)
|
||||
- [ ] Resolved identity stored on the `CallConnection` (for observability + AccessControl)
|
||||
- [ ] WS binary message = one `EventEnvelope` (JSON serde, no length prefix)
|
||||
- [ ] No `FrameFramedReader`/`FrameFramedWriter` on the WS path (WS message boundary is delimiter)
|
||||
- [ ] Text WS messages rejected (protocol-level close)
|
||||
- [ ] `call.requested` → `Dispatcher::dispatch_requested` (the pub API)
|
||||
- [ ] `AccessControl::check(identity)` gates every `call.requested`
|
||||
- [ ] `FORBIDDEN` → `call.error` event (before handler runs)
|
||||
- [ ] `call.responded`/`call.completed`/`call.aborted` correlated by `id` via `PendingRequestMap`
|
||||
- [ ] Response `EventEnvelope` frames written as binary WS messages
|
||||
- [ ] `call.aborted` → the pub abort-handling method
|
||||
- [ ] Bidirectionality: hub can `call.requested` to browser-registered ops
|
||||
- [ ] `Subscription` streams `call.responded` as binary WS messages (no SSE)
|
||||
- [ ] `call.completed` closes subscription; `call.aborted` closes with error
|
||||
- [ ] WS client disconnect mid-subscription → `call.aborted` (ADR-016 cascade)
|
||||
- [ ] WS close → fail all pending, drop overlay (connection-local)
|
||||
- [ ] Upgrade works over HTTP/1.1 (RFC 6455) and HTTP/2 (RFC 8441)
|
||||
- [ ] Handler does not branch on HTTP version (WS frame stream is same post-upgrade)
|
||||
- [ ] Integration test: WS upgrade → `call.requested` → `call.responded` round-trip
|
||||
- [ ] Integration test: no Bearer token → 401
|
||||
- [ ] Integration test: `AccessControl` denied → `call.error` FORBIDDEN
|
||||
- [ ] Integration test: `Subscription` over WS → multiple `call.responded` + `call.completed`
|
||||
- [ ] Integration test: WS disconnect mid-subscription → `call.aborted` cascade
|
||||
- [ ] Integration test: text WS message → protocol close
|
||||
- [ ] Integration test: bidirectional (hub calls browser-registered op)
|
||||
- [ ] `cargo test -p alknet-http` succeeds
|
||||
- [ ] `cargo clippy -p alknet-http --all-targets` succeeds with no warnings
|
||||
|
||||
## References
|
||||
|
||||
- docs/architecture/crates/http/websocket.md — full WS spec (upgrade handler, framing, dispatch, bidirectionality, streaming)
|
||||
- docs/architecture/decisions/044-defer-webtransport-browsers-use-websocket.md — ADR-044 (WS is v1 browser path, no length prefix)
|
||||
- docs/architecture/decisions/048-websocket-native-session-not-gateway.md — ADR-048 (native session, not gateway shape)
|
||||
- docs/architecture/decisions/012-call-protocol-stream-model.md — ADR-012 (stream-agnostic correlation)
|
||||
- docs/architecture/decisions/016-abort-cascade-for-nested-calls.md — ADR-016 (disconnect → abort cascade)
|
||||
- docs/architecture/decisions/029-peer-graph-routing-model.md — ADR-029 §3 (AccessControl::check is sole gate)
|
||||
- docs/architecture/decisions/046-assembly-layer-custom-http-routes.md — ADR-046 (collision rule for /alknet/call)
|
||||
- /workspace/@alkdev/pubsub/src/event-target-websocket-client.ts — TypeScript prior art (EventEnvelope over WS binary messages)
|
||||
|
||||
## Notes
|
||||
|
||||
> The WS path is the native EventEnvelope session, not the gateway shape
|
||||
> (ADR-048). The gateway endpoints are HTTP-only; discovery is via
|
||||
> services/list/services/schema as call-protocol ops. The WS path carries
|
||||
> no length prefix (ADR-044 Assumption 1 — the WS message boundary is the
|
||||
> delimiter, unlike QUIC's 4-byte prefix). Text messages are rejected. The
|
||||
> dispatch uses the pub API exposed by the dispatcher-transport-abstraction
|
||||
> task (dispatch_requested + abort-handling + non-QUIC CallConnection).
|
||||
> Bidirectionality: both sides can call.requested (ADR-043 §2 transferred
|
||||
> per ADR-044 §3). Streaming is native call.responded events, no SSE. The
|
||||
> default upgrade path is /alknet/call (namespaces away from reserved paths
|
||||
> per ADR-046). This is the second-highest-risk task (after the transport
|
||||
> abstraction) — the WS dispatch loop must be identical to the QUIC dispatch
|
||||
> loop on the security axis (AccessControl, identity, abort cascade).
|
||||
|
||||
## Summary
|
||||
|
||||
> To be filled on completion
|
||||
Reference in New Issue
Block a user