feat: server foundation (phase 1 core) — state, auth, healthz/decoy, gateway dispatch, HttpAdapter
Tasks completed: server-core-types, server-auth, server-healthz-decoy, gateway-dispatch, server-adapter (5 of 17). - src/server/state.rs: DecoyConfig + RouterState (alkcall type paths, 6-endpoint reserved-path docs) - src/server/auth.rs: bearer middleware + ResolvedIdentity extractor (10 tests: missing/malformed/basic/failed-resolution matrix) - src/server/healthz.rs + decoy.rs: raw healthz; nginx-style 404, static site (path-traversal guarded), redirect decoys - src/gateway/dispatch.rs: GatewayDispatch invoke/invoke_streaming (internal:false, forwarded_for:None, bounded deadline) + src/gateway/error.rs: CallError→HTTP status mapping (HTTP_<status> passthrough, retryable→Retry-After) - src/server/adapter.rs: HttpAdapter ProtocolHandler — accept_bi → BiStream → TokioIo → hyper auto builder (h2 CONNECT enabled); integration tests over DuplexStream (request/response cycle, healthz, decoy 404) Verified: cargo test (46 lib tests), clippy -D warnings, fmt, test --all-features.
This commit is contained in:
@@ -0,0 +1,289 @@
|
||||
//! Shared dispatch spine for the `to_openapi` / `to_mcp` gateway
|
||||
//! projections.
|
||||
//!
|
||||
//! Thin concrete struct (not a trait — the alknet research ruled out a
|
||||
//! trait with an associated output type). Holds `Arc<OperationRegistry>`
|
||||
//! and `Arc<dyn IdentityProvider>` and exposes a `resolve_bearer()` and
|
||||
//! `invoke()` method pair returning the neutral `ResponseEnvelope`. Each
|
||||
//! gateway maps the envelope to its own wire shape (`to_openapi` → HTTP
|
||||
//! `Response`, `to_mcp` → `CallToolResult`).
|
||||
//!
|
||||
//! # Security invariants
|
||||
//!
|
||||
//! - `internal: false` — ACL runs against the caller's `identity`, not a
|
||||
//! handler's composition authority (alkcall ADR-017).
|
||||
//! - `forwarded_for: None` — wire-ingress only.
|
||||
//!
|
||||
//! The root `OperationContext` is constructed identically for both
|
||||
//! gateways, making them provably identical on the security axis (auth,
|
||||
//! authority, ACL); they diverge only on wire-framing.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use alkcall::core::auth::{AuthToken, Identity, IdentityProvider};
|
||||
use alkcall::core::types::Capabilities;
|
||||
use alkcall::protocol::wire::ResponseEnvelope;
|
||||
use alkcall::registry::context::{AbortPolicy, OperationContext, ScopedPeerEnv};
|
||||
use alkcall::registry::env::LocalOperationEnv;
|
||||
use alkcall::registry::registration::OperationRegistry;
|
||||
use futures::stream::BoxStream;
|
||||
use serde_json::Value;
|
||||
|
||||
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
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 {
|
||||
Self {
|
||||
registry,
|
||||
identity_provider,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn registry(&self) -> &Arc<OperationRegistry> {
|
||||
&self.registry
|
||||
}
|
||||
|
||||
pub fn identity_provider(&self) -> &Arc<dyn IdentityProvider> {
|
||||
&self.identity_provider
|
||||
}
|
||||
|
||||
pub fn resolve_bearer(&self, token: &AuthToken) -> Option<Identity> {
|
||||
self.identity_provider.resolve_from_token(token)
|
||||
}
|
||||
|
||||
pub async fn invoke(
|
||||
&self,
|
||||
identity: Option<Identity>,
|
||||
op: &str,
|
||||
input: Value,
|
||||
) -> ResponseEnvelope {
|
||||
let operation_name = strip_leading_slash(op).to_string();
|
||||
let request_id = uuid::Uuid::new_v4().to_string();
|
||||
let context = self.build_root_context(&request_id, &operation_name, identity);
|
||||
self.registry.invoke(&operation_name, input, context).await
|
||||
}
|
||||
|
||||
pub fn invoke_streaming(
|
||||
&self,
|
||||
identity: Option<Identity>,
|
||||
op: &str,
|
||||
input: Value,
|
||||
) -> BoxStream<'static, ResponseEnvelope> {
|
||||
let operation_name = strip_leading_slash(op).to_string();
|
||||
let request_id = uuid::Uuid::new_v4().to_string();
|
||||
let context = self.build_root_context_streaming(&request_id, &operation_name, identity);
|
||||
self.registry
|
||||
.invoke_streaming(&operation_name, input, context)
|
||||
}
|
||||
|
||||
fn build_root_context(
|
||||
&self,
|
||||
request_id: &str,
|
||||
operation_name: &str,
|
||||
identity: Option<Identity>,
|
||||
) -> OperationContext {
|
||||
self.build_root_context_inner(request_id, operation_name, identity, true)
|
||||
}
|
||||
|
||||
fn build_root_context_streaming(
|
||||
&self,
|
||||
request_id: &str,
|
||||
operation_name: &str,
|
||||
identity: Option<Identity>,
|
||||
) -> OperationContext {
|
||||
self.build_root_context_inner(request_id, operation_name, identity, false)
|
||||
}
|
||||
|
||||
fn build_root_context_inner(
|
||||
&self,
|
||||
request_id: &str,
|
||||
operation_name: &str,
|
||||
identity: Option<Identity>,
|
||||
bounded: bool,
|
||||
) -> OperationContext {
|
||||
let registration = self.registry.registration(operation_name);
|
||||
let (composition_authority, capabilities, scoped_env) = match registration {
|
||||
Some(r) => (
|
||||
r.composition_authority.clone(),
|
||||
r.capabilities.clone(),
|
||||
r.scoped_env.clone().unwrap_or_else(ScopedPeerEnv::empty),
|
||||
),
|
||||
None => (None, Capabilities::new(), ScopedPeerEnv::empty()),
|
||||
};
|
||||
|
||||
let env: Arc<dyn alkcall::registry::env::OperationEnv + Send + Sync> =
|
||||
Arc::new(LocalOperationEnv::new(Arc::clone(&self.registry)));
|
||||
|
||||
OperationContext {
|
||||
request_id: request_id.to_string(),
|
||||
parent_request_id: None,
|
||||
identity,
|
||||
handler_identity: composition_authority,
|
||||
forwarded_for: None,
|
||||
capabilities,
|
||||
metadata: HashMap::new(),
|
||||
deadline: bounded.then(|| Instant::now() + DEFAULT_TIMEOUT),
|
||||
scoped_env,
|
||||
env,
|
||||
abort_policy: AbortPolicy::default(),
|
||||
internal: false,
|
||||
ownership: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn strip_leading_slash(operation_id: &str) -> &str {
|
||||
operation_id.strip_prefix('/').unwrap_or(operation_id)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use alkcall::registry::registration::{
|
||||
make_handler, make_streaming_handler, HandlerKind, HandlerRegistration, OperationProvenance,
|
||||
};
|
||||
use alkcall::registry::spec::{AccessControl, OperationSpec, OperationType, Visibility};
|
||||
use futures::StreamExt;
|
||||
use std::sync::Mutex as StdMutex;
|
||||
|
||||
struct StaticIdentityProvider {
|
||||
tokens: StdMutex<HashMap<String, Identity>>,
|
||||
}
|
||||
|
||||
impl StaticIdentityProvider {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
tokens: StdMutex::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl IdentityProvider for StaticIdentityProvider {
|
||||
fn resolve_from_fingerprint(&self, _fp: &str) -> Option<Identity> {
|
||||
None
|
||||
}
|
||||
fn resolve_from_token(&self, token: &AuthToken) -> Option<Identity> {
|
||||
let token_str = String::from_utf8_lossy(&token.raw);
|
||||
self.tokens.lock().unwrap().get(token_str.as_ref()).cloned()
|
||||
}
|
||||
}
|
||||
|
||||
fn spec(name: &str, visibility: Visibility, op_type: OperationType) -> OperationSpec {
|
||||
OperationSpec::new(
|
||||
name,
|
||||
op_type,
|
||||
visibility,
|
||||
serde_json::json!({}),
|
||||
serde_json::json!({}),
|
||||
vec![],
|
||||
AccessControl::default(),
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
fn echo_registry() -> Arc<OperationRegistry> {
|
||||
let mut registry = OperationRegistry::new();
|
||||
registry
|
||||
.register(HandlerRegistration::new(
|
||||
spec("echo/run", Visibility::External, OperationType::Query),
|
||||
HandlerKind::Once(make_handler(|input, ctx| async move {
|
||||
ResponseEnvelope::ok(ctx.request_id, input)
|
||||
})),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
))
|
||||
.unwrap();
|
||||
Arc::new(registry)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invoke_external_op_round_trips() {
|
||||
let dispatch =
|
||||
GatewayDispatch::new(echo_registry(), Arc::new(StaticIdentityProvider::new()));
|
||||
let envelope = dispatch
|
||||
.invoke(None, "/echo/run", serde_json::json!({ "x": 1 }))
|
||||
.await;
|
||||
assert!(envelope.result.is_ok(), "expected ok, got {envelope:?}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invoke_unknown_op_returns_not_found() {
|
||||
let dispatch =
|
||||
GatewayDispatch::new(echo_registry(), Arc::new(StaticIdentityProvider::new()));
|
||||
let envelope = dispatch
|
||||
.invoke(None, "/missing/op", serde_json::json!({}))
|
||||
.await;
|
||||
match envelope.result {
|
||||
Err(error) => assert_eq!(error.code, "NOT_FOUND"),
|
||||
Ok(v) => panic!("expected error, got {v:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invoke_internal_op_returns_not_found() {
|
||||
let mut registry = OperationRegistry::new();
|
||||
registry
|
||||
.register(HandlerRegistration::new(
|
||||
spec("internal/op", Visibility::Internal, OperationType::Query),
|
||||
HandlerKind::Once(make_handler(|_input, ctx| async move {
|
||||
ResponseEnvelope::ok(ctx.request_id, serde_json::json!({}))
|
||||
})),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
))
|
||||
.unwrap();
|
||||
let dispatch =
|
||||
GatewayDispatch::new(Arc::new(registry), Arc::new(StaticIdentityProvider::new()));
|
||||
let envelope = dispatch
|
||||
.invoke(None, "/internal/op", serde_json::json!({}))
|
||||
.await;
|
||||
match envelope.result {
|
||||
Err(error) => assert_eq!(error.code, "NOT_FOUND"),
|
||||
Ok(v) => panic!("expected error, got {v:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn streaming_sub_op_streams_envelopes() {
|
||||
let mut registry = OperationRegistry::new();
|
||||
registry
|
||||
.register(HandlerRegistration::new(
|
||||
spec("tick/stream", Visibility::External, OperationType::Sub),
|
||||
HandlerKind::Stream(make_streaming_handler(|input, ctx| {
|
||||
let count = input.get("count").and_then(|v| v.as_u64()).unwrap_or(2);
|
||||
let request_id = ctx.request_id.clone();
|
||||
futures::stream::iter(0..count).map(move |i| {
|
||||
ResponseEnvelope::ok(request_id.clone(), serde_json::json!({ "tick": i }))
|
||||
})
|
||||
})),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
))
|
||||
.unwrap();
|
||||
let dispatch =
|
||||
GatewayDispatch::new(Arc::new(registry), Arc::new(StaticIdentityProvider::new()));
|
||||
let mut stream =
|
||||
dispatch.invoke_streaming(None, "/tick/stream", serde_json::json!({ "count": 3 }));
|
||||
let mut ticks = Vec::new();
|
||||
while let Some(envelope) = stream.next().await {
|
||||
ticks.push(envelope);
|
||||
}
|
||||
assert_eq!(ticks.len(), 3);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
//! CallError → HTTP status/response mapping ([ADR-023]).
|
||||
//!
|
||||
//! Protocol-level vs operation-level code distinction: protocol codes
|
||||
//! (`NOT_FOUND`, `FORBIDDEN`, `INVALID_INPUT`, `TIMEOUT`, `INTERNAL`)
|
||||
//! map to fixed statuses; operation-level codes imported from external
|
||||
//! HTTP APIs are prefixed `HTTP_<status>` and map to their declared
|
||||
//! status.
|
||||
//!
|
||||
//! [ADR-023]: crate::docs
|
||||
|
||||
use alkcall::core::auth::Identity;
|
||||
use alkcall::protocol::wire::CallError;
|
||||
use axum::http::{header, HeaderValue, StatusCode};
|
||||
use axum::response::{IntoResponse, Json, Response};
|
||||
use serde_json::Value;
|
||||
|
||||
const PROTOCOL_CODE_NOT_FOUND: &str = "NOT_FOUND";
|
||||
const PROTOCOL_CODE_FORBIDDEN: &str = "FORBIDDEN";
|
||||
const PROTOCOL_CODE_INVALID_INPUT: &str = "INVALID_INPUT";
|
||||
const PROTOCOL_CODE_TIMEOUT: &str = "TIMEOUT";
|
||||
const PROTOCOL_CODE_INTERNAL: &str = "INTERNAL";
|
||||
|
||||
const HTTP_PREFIX: &str = "HTTP_";
|
||||
|
||||
const STATUS_NOT_FOUND: u16 = 404;
|
||||
const STATUS_UNAUTHORIZED: u16 = 401;
|
||||
const STATUS_FORBIDDEN: u16 = 403;
|
||||
const STATUS_UNPROCESSABLE: u16 = 422;
|
||||
const STATUS_TIMEOUT: u16 = 504;
|
||||
const STATUS_INTERNAL: u16 = 500;
|
||||
|
||||
const RETRY_AFTER_STATUSES: &[u16] = &[429, 503];
|
||||
|
||||
pub fn call_error_to_http_status(error: &CallError) -> u16 {
|
||||
call_error_to_http_status_with_identity(error, None)
|
||||
}
|
||||
|
||||
pub fn call_error_to_http_status_with_identity(
|
||||
error: &CallError,
|
||||
identity: Option<&Identity>,
|
||||
) -> u16 {
|
||||
match error.code.as_str() {
|
||||
PROTOCOL_CODE_NOT_FOUND => STATUS_NOT_FOUND,
|
||||
PROTOCOL_CODE_FORBIDDEN => {
|
||||
if identity.is_some() {
|
||||
STATUS_FORBIDDEN
|
||||
} else {
|
||||
STATUS_UNAUTHORIZED
|
||||
}
|
||||
}
|
||||
PROTOCOL_CODE_INVALID_INPUT => STATUS_UNPROCESSABLE,
|
||||
PROTOCOL_CODE_TIMEOUT => STATUS_TIMEOUT,
|
||||
PROTOCOL_CODE_INTERNAL => STATUS_INTERNAL,
|
||||
code if code.starts_with(HTTP_PREFIX) => code[HTTP_PREFIX.len()..]
|
||||
.parse::<u16>()
|
||||
.unwrap_or(STATUS_INTERNAL),
|
||||
_ => STATUS_INTERNAL,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn call_error_to_http_response(error: &CallError) -> Response {
|
||||
let status_code = call_error_to_http_status(error);
|
||||
let status = status_code_from_u16(status_code);
|
||||
let body = serde_json::to_value(error).unwrap_or(Value::Null);
|
||||
|
||||
let retry_after = retry_after_value(error, status_code);
|
||||
|
||||
if let Some(retry_after) = retry_after {
|
||||
let header_value =
|
||||
HeaderValue::from_str(&retry_after).unwrap_or_else(|_| HeaderValue::from_static("0"));
|
||||
(status, [(header::RETRY_AFTER, header_value)], Json(body)).into_response()
|
||||
} else {
|
||||
(status, Json(body)).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
fn status_code_from_u16(code: u16) -> StatusCode {
|
||||
StatusCode::from_u16(code).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR)
|
||||
}
|
||||
|
||||
fn retry_after_value(error: &CallError, status_code: u16) -> Option<String> {
|
||||
if !error.retryable || !RETRY_AFTER_STATUSES.contains(&status_code) {
|
||||
return None;
|
||||
}
|
||||
error
|
||||
.details
|
||||
.as_ref()
|
||||
.and_then(|details| details.get("retry_after"))
|
||||
.and_then(Value::as_str)
|
||||
.map(|s| s.to_string())
|
||||
.or_else(|| {
|
||||
error
|
||||
.details
|
||||
.as_ref()
|
||||
.and_then(|details| details.get("retry_after"))
|
||||
.and_then(Value::as_u64)
|
||||
.map(|n| n.to_string())
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::HashMap;
|
||||
|
||||
fn identity() -> Identity {
|
||||
Identity {
|
||||
id: "caller".to_string(),
|
||||
scopes: vec!["read".to_string()],
|
||||
resources: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn not_found_maps_to_404() {
|
||||
let error = CallError::not_found("fs/missing");
|
||||
assert_eq!(call_error_to_http_status(&error), 404);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_input_maps_to_422() {
|
||||
let error = CallError::invalid_input("bad input");
|
||||
assert_eq!(call_error_to_http_status(&error), 422);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timeout_maps_to_504() {
|
||||
let error = CallError::timeout("timed out");
|
||||
assert_eq!(call_error_to_http_status(&error), 504);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn internal_maps_to_500() {
|
||||
let error = CallError::internal("boom");
|
||||
assert_eq!(call_error_to_http_status(&error), 500);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forbidden_with_none_identity_maps_to_401() {
|
||||
let error = CallError::forbidden("auth required");
|
||||
assert_eq!(call_error_to_http_status_with_identity(&error, None), 401);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forbidden_with_some_identity_maps_to_403() {
|
||||
let error = CallError::forbidden("insufficient scopes");
|
||||
let id = identity();
|
||||
assert_eq!(
|
||||
call_error_to_http_status_with_identity(&error, Some(&id)),
|
||||
403
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn http_prefixed_code_maps_to_declared_status() {
|
||||
let error = CallError::new("HTTP_404", "not found", false);
|
||||
assert_eq!(call_error_to_http_status(&error), 404);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn http_prefixed_code_with_unparseable_status_maps_to_500() {
|
||||
let error = CallError::new("HTTP_", "malformed", false);
|
||||
assert_eq!(call_error_to_http_status(&error), 500);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_domain_code_maps_to_500() {
|
||||
let error = CallError::new("DOMAIN_SPECIFIC", "domain error", false);
|
||||
assert_eq!(call_error_to_http_status(&error), 500);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retryable_503_with_retry_after_details_sets_header() {
|
||||
let error = CallError::new("HTTP_503", "overloaded", true)
|
||||
.with_details(serde_json::json!({ "retry_after": "30" }));
|
||||
let resp = call_error_to_http_response(&error);
|
||||
assert_eq!(resp.status(), 503);
|
||||
let retry_after = resp
|
||||
.headers()
|
||||
.get(header::RETRY_AFTER)
|
||||
.map(|v| v.to_str().unwrap().to_string());
|
||||
assert_eq!(retry_after.as_deref(), Some("30"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_retryable_503_does_not_set_retry_after() {
|
||||
let error = CallError::new("HTTP_503", "overloaded", false)
|
||||
.with_details(serde_json::json!({ "retry_after": "30" }));
|
||||
let resp = call_error_to_http_response(&error);
|
||||
assert_eq!(resp.status(), 503);
|
||||
assert!(resp.headers().get(header::RETRY_AFTER).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_body_serializes_the_call_error() {
|
||||
let error = CallError::not_found("fs/missing");
|
||||
let resp = call_error_to_http_response(&error);
|
||||
let bytes = futures::executor::block_on(axum::body::to_bytes(resp.into_body(), usize::MAX))
|
||||
.unwrap();
|
||||
let body: Value = serde_json::from_slice(&bytes).unwrap();
|
||||
assert_eq!(body["code"], "NOT_FOUND");
|
||||
assert_eq!(body["message"], "operation not found: fs/missing");
|
||||
}
|
||||
}
|
||||
@@ -1 +1,4 @@
|
||||
pub mod dispatch;
|
||||
pub mod error;
|
||||
|
||||
pub use dispatch::GatewayDispatch;
|
||||
|
||||
Reference in New Issue
Block a user