feat(server): thread alkcall 0.10 ownership wiring onto the HTTP surface

Ride the alkcall 0.10 gateway ownership seam (review 012, ADR-048
amendment) into the door layer:

- HttpAdapter::with_ownership_provider /
  HttpAdapter::with_static_ownership_fallback mirror alkcall's builder
  shape; OwnershipProvider re-exported next to GatewayDispatch
- RouterState carries the wiring; GatewayState::dispatch is the single
  spine builder for both the gateway routes and the MCP nest
- loud default passes through: a resource_type-gated op on an unwired
  spine is refused (FORBIDDEN envelope -> 403/401) on HTTP exactly as
  on the native wire
- WS native-session path untouched (consults ownership natively)

fix: with_ws_max_sessions never stored the fresh session-slot
semaphore; later builder calls silently reverted the cap. Builder
state now assembled through one router_state() helper.

infra: fuzz/shared follows to alkcall 0.10 (two alkcall copies in one
build would type-clash); alkhttp 0.8.0 per the dep-wave convention.

Verification: cargo test (444 pass, incl. 3 new call_resource_gated_*
HTTP-surface pins), clippy --all-targets -D warnings, fmt --check,
test --all-features (520), fuzz corpus replay (21/21), doc.
This commit is contained in:
glm-5.3-flash committed 2026-10-17 16:00:12 +00:00
1 parent 1cac63fdba
commit ad63507124
11 files changed
+435 -93

No files matched your search

+35
View File
@@ -4,6 +4,41 @@ All notable changes to this crate are documented here. The format is
based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
this crate adheres to [Semantic Versioning](https://semver.org/). this crate adheres to [Semantic Versioning](https://semver.org/).
## [0.8.0] — 2026-10-17
### Changed
- **`alkcall` dependency bumped to 0.10.0** (the gateway ownership-seam
release, alkcall review 012) and the loud ownership default passes
through to the HTTP surface (ADR-072):
`HttpAdapter::with_ownership_provider` /
`HttpAdapter::with_static_ownership_fallback` thread the provider
into both spines this crate constructs — the gateway endpoints'
per-request spine and the MCP nest's spine (single wiring point in
`GatewayState::dispatch`). An unwired, undeclared spine now refuses
a `resource_type`-gated op with a `FORBIDDEN` envelope (**403** with
identity / **401** without) — the behavior break is inherited, not
re-decided; the WS native-session path is untouched (it consults
ownership natively).
- The gateway test-state builder threads ownership configuration; the
three dispositions (unwired refusal, live grant table, declared
static fallback) are pinned at the HTTP surface by
`call_resource_gated_*` regression tests.
### Fixed
- `HttpAdapter::with_ws_max_sessions` never stored the freshly built
session-slot semaphore on the adapter — any subsequent builder call
rebuilt the router with the pre-cap-change semaphore, silently
reverting the cap. Builder state is now assembled in one shared
`router_state()` helper, so every `with_*` rebuild sees the current
configuration.
Minor bump 0.7.0 → 0.8.0 per the dep-wave convention (0.4.0 rode
alkcall 0.7.0, 0.5.0 rode 0.8.0, 0.7.0 rode 0.9.0): the re-exported
alkcall types are on this crate's public API, so a semver-moving
upstream is semver-relevant here.
## [0.7.0] — 2026-10-14 ## [0.7.0] — 2026-10-14
### Changed ### Changed
Generated
+3 -3
View File
@@ -27,9 +27,9 @@ dependencies = [
[[package]] [[package]]
name = "alkcall" name = "alkcall"
version = "0.9.0" version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a3562f1f7b348cdeb3d9181eaf57b0a2fb3f988d4280e8e225dadfd7f763cb84" checksum = "6b8ef0591beff0e3fe64b7277a67d8e7caeccf71c27538b4b7aced028d22ca44"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"bytes", "bytes",
@@ -48,7 +48,7 @@ dependencies = [
[[package]] [[package]]
name = "alkhttp" name = "alkhttp"
version = "0.7.0" version = "0.8.0"
dependencies = [ dependencies = [
"alkcall", "alkcall",
"arc-swap", "arc-swap",
+2 -2
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "alkhttp" name = "alkhttp"
version = "0.7.0" version = "0.8.0"
edition = "2021" edition = "2021"
rust-version = "1.88" rust-version = "1.88"
license = "MIT OR Apache-2.0" license = "MIT OR Apache-2.0"
@@ -46,7 +46,7 @@ h2 = ["server", "dep:hyper", "hyper-util/http2", "hyper/http2"]
http1 = ["server", "dep:hyper", "hyper-util/http1", "hyper/http1"] http1 = ["server", "dep:hyper", "hyper-util/http1", "hyper/http1"]
[dependencies] [dependencies]
alkcall = { version = "0.9", features = ["gateway"] } alkcall = { version = "0.10", features = ["gateway"] }
arc-swap = { version = "1", optional = true } arc-swap = { version = "1", optional = true }
axum = { version = "0.8", optional = true, features = ["ws"] } axum = { version = "0.8", optional = true, features = ["ws"] }
bytes = "1" bytes = "1"
+1
View File
@@ -69,6 +69,7 @@ types — the former `alknet-core` and `alknet-call` merged).
| [069](decisions/069-webtransport-out-of-scope.md) | WebTransport Out of Scope | h3/WebTransport removed from alkhttp scope entirely (an alknet concern); supersedes the deferral framing of ADR-044 | | [069](decisions/069-webtransport-out-of-scope.md) | WebTransport Out of Scope | h3/WebTransport removed from alkhttp scope entirely (an alknet concern); supersedes the deferral framing of ADR-044 |
| [070](decisions/070-from-wss-consumer-adapter.md) | `from_wss` Consumer Adapter | Import a remote node's operations over WSS — same-protocol importer, channels-over-WS as transport; `wss` feature gate | | [070](decisions/070-from-wss-consumer-adapter.md) | `from_wss` Consumer Adapter | Import a remote node's operations over WSS — same-protocol importer, channels-over-WS as transport; `wss` feature gate |
| [071](decisions/071-dispatch-schema-guard.md) | Dispatch-Spine `services/schema` Op-Path Guard | `GatewayDispatch` applies the GET `/schema` visibility+ACL checks to the meta-op's inner `name` (review-002 PRJ-16); alkcall CF-004 is the complete fix, this stays as defense-in-depth (now via the promoted `alkcall::gateway` shared check — spine + guard themselves live in alkcall per ADR-048) | | [071](decisions/071-dispatch-schema-guard.md) | Dispatch-Spine `services/schema` Op-Path Guard | `GatewayDispatch` applies the GET `/schema` visibility+ACL checks to the meta-op's inner `name` (review-002 PRJ-16); alkcall CF-004 is the complete fix, this stays as defense-in-depth (now via the promoted `alkcall::gateway` shared check — spine + guard themselves live in alkcall per ADR-048) |
| [072](decisions/072-http-surface-ownership-wiring.md) | HTTP-Surface Ownership Wiring (Gateway + MCP) | alkcall 0.10's loud ownership default passes through: `HttpAdapter::with_ownership_provider` / `with_static_ownership_fallback` thread the provider into the gateway and MCP spines (single wiring in `GatewayState::dispatch`) |
## Relevant Open Questions ## Relevant Open Questions
@@ -0,0 +1,89 @@
# ADR-072: HTTP-Surface Ownership Wiring (Gateway + MCP)
## Status
Accepted
## Context
alkcall 0.10.0 landed the gateway ownership seam (alkcall review 012,
downstream evidence from alkgit's LFS POC): the dispatch spine's
previously-silent static `Identity.resources` fallback becomes loud — a
`resource_type`-gated op served through an unwired spine is refused
with a `FORBIDDEN` error envelope unless the deployment wires a
runtime ownership provider
(`GatewayDispatch::with_ownership_provider`) or declares the static
fallback explicitly
(`GatewayDispatch::with_static_ownership_fallback`). The change closes
the two-authorization-paths drift: a namespace-ACL-gated op served
through an HTTP door previously ran against the token-carried static
claim, while the same op on the native wire consulted the revocable
grant table.
alkhttp is the only live spine consumer. It constructs the spine
internally in two places — the gateway endpoints' per-request spine
(`gateway::routes::GatewayState`) and the MCP nest's spine
(`server::build_router`) — so an assembly layer wiring a provider for
the native wire cannot reach those spines through alkcall's API. Once
alkhttp rode 0.10, any `resource_type`-gated op in the registry would
break on the HTTP surface even for deployments that wired providers
correctly.
## Decision
**alkhttp mirrors alkcall's builder shape on `HttpAdapter` and threads
the wiring into every spine it constructs.**
- `HttpAdapter::with_ownership_provider(Arc<dyn OwnershipProvider>)`
and `HttpAdapter::with_static_ownership_fallback()` — the two
alkcall-named opt-ins, one method each, no alkcall-specific
vocabulary changed. `OwnershipProvider` is re-exported from
`alkhttp::gateway` next to `GatewayDispatch`.
- The wiring lives on `RouterState` and is threaded by
`GatewayState::dispatch()` (routes) and `RouterState::dispatch()`
(the MCP nest — the gateway routes extract `GatewayState` via
`FromRef`, so `GatewayState` is the single wiring point).
- Both spines share one wiring: the gateway endpoints and the MCP
surface are the same door contract; no separate MCP wiring exists.
The door contract (alkcall review 012, unchanged here): any
deployment serving `resource_type`-gated ops through the HTTP surface
must wire the same provider the native wire consults —
visible-surface = authorized-surface. The loud default passes through
from alkcall unchanged: an unwired, undeclared spine refuses a
`resource_type`-gated op with the spine's `FORBIDDEN` envelope, which
the gateway error mapping serves as 403 (with identity) / 401
(without).
The WS native-session path needs no wiring — it speaks the native
call protocol, which consults ownership natively in alkcall's serving
layer (alkcall ADR-048).
## Consequences
**Positive:**
- The HTTP surface cannot silently diverge from the native wire's
ownership truth — alkcall's loud default is inheritable at the door
layer, and `with_static_ownership_fallback` keeps the static-claims
posture a code-review-visible declaration here too.
- alkcall's builder shape is mirrored on alkhttp's builder, so an
assembly layer configures both spines the same way.
**Negative:**
- Deployments upgrading alkhttp to the 0.10-riding release must wire
the provider (or declare the fallback) before serving
`resource_type`-gated ops over HTTP — the 403 refusal is the
reminder, not a compile error.
## References
- alkcall review 012
(`/workspace/@alkdev/alkcall/docs/reviews/012-gateway-ownership-seam-consumer-input.md`)
— the consumer evidence and the two recorded decisions
- alkcall 0.10.0 CHANGELOG (the loud-default behavior break)
- [ADR-048](048-websocket-native-session-not-gateway.md) — the WS path
this does not touch
- [Review 008](../../reviews/008-http-door-consumer-review.md)
— the same consumption round's alkhttp-filed findings (AH-1, AH-2)
+6 -6
View File
@@ -27,9 +27,9 @@ dependencies = [
[[package]] [[package]]
name = "alkcall" name = "alkcall"
version = "0.9.0" version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a3562f1f7b348cdeb3d9181eaf57b0a2fb3f988d4280e8e225dadfd7f763cb84" checksum = "6b8ef0591beff0e3fe64b7277a67d8e7caeccf71c27538b4b7aced028d22ca44"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"bytes", "bytes",
@@ -48,7 +48,7 @@ dependencies = [
[[package]] [[package]]
name = "alkhttp" name = "alkhttp"
version = "0.6.0" version = "0.8.0"
dependencies = [ dependencies = [
"alkcall", "alkcall",
"arc-swap", "arc-swap",
@@ -1405,7 +1405,7 @@ dependencies = [
"once_cell", "once_cell",
"socket2", "socket2",
"tracing", "tracing",
"windows-sys 0.52.0", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -1756,7 +1756,7 @@ dependencies = [
"security-framework", "security-framework",
"security-framework-sys", "security-framework-sys",
"webpki-root-certs", "webpki-root-certs",
"windows-sys 0.52.0", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -2513,7 +2513,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [ dependencies = [
"windows-sys 0.52.0", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
+1 -1
View File
@@ -14,7 +14,7 @@ mcp = ["alkhttp/mcp"]
[dependencies] [dependencies]
alkhttp = { path = "../.." } alkhttp = { path = "../.." }
alkcall = "0.9" alkcall = "0.10"
serde_json = "1" serde_json = "1"
bytes = "1" bytes = "1"
futures = "0.3" futures = "0.3"
+3
View File
@@ -1,8 +1,11 @@
pub mod error; pub mod error;
pub mod routes; pub mod routes;
pub use alkcall::core::ownership::OwnershipProvider;
pub use alkcall::gateway::{schema_disclosure_denial, GatewayDispatch, DEFAULT_DEADLINE}; pub use alkcall::gateway::{schema_disclosure_denial, GatewayDispatch, DEFAULT_DEADLINE};
pub use routes::CallRequest; pub use routes::CallRequest;
#[cfg(feature = "mcp")]
pub(crate) use routes::GatewayState;
/// The maximum number of operations accepted in a single batch, /// The maximum number of operations accepted in a single batch,
/// enforced identically by the HTTP `POST /batch` endpoint, the /// enforced identically by the HTTP `POST /batch` endpoint, the
+196 -6
View File
@@ -82,21 +82,30 @@ type ByteStream = futures::stream::BoxStream<'static, Result<Bytes, axum::Error>
#[derive(Clone)] #[derive(Clone)]
pub(crate) struct GatewayState { pub(crate) struct GatewayState {
registry: Arc<OperationRegistry>, registry: Arc<OperationRegistry>,
ownership_provider: Option<Arc<dyn alkcall::core::ownership::OwnershipProvider>>,
static_ownership_fallback: bool,
} }
impl GatewayState { impl GatewayState {
pub(crate) fn new(registry: Arc<OperationRegistry>) -> Self { pub(crate) fn dispatch(&self) -> GatewayDispatch {
Self { registry } let mut dispatch = GatewayDispatch::new(Arc::clone(&self.registry));
if let Some(provider) = &self.ownership_provider {
dispatch = dispatch.with_ownership_provider(Arc::clone(provider));
} }
if self.static_ownership_fallback {
fn dispatch(&self) -> GatewayDispatch { dispatch = dispatch.with_static_ownership_fallback();
GatewayDispatch::new(Arc::clone(&self.registry)) }
dispatch
} }
} }
impl FromRef<RouterState> for GatewayState { impl FromRef<RouterState> for GatewayState {
fn from_ref(state: &RouterState) -> Self { fn from_ref(state: &RouterState) -> Self {
GatewayState::new(Arc::clone(&state.registry)) Self {
registry: Arc::clone(&state.registry),
ownership_provider: state.ownership_provider.clone(),
static_ownership_fallback: state.static_ownership_fallback,
}
} }
} }
@@ -803,6 +812,7 @@ fn error_event(operation: &str) -> Result<Event, Infallible> {
mod tests { mod tests {
use super::*; use super::*;
use alkcall::core::auth::IdentityProvider; use alkcall::core::auth::IdentityProvider;
use alkcall::core::ownership::OwnershipStore;
use alkcall::core::types::Capabilities; use alkcall::core::types::Capabilities;
use alkcall::registry::discovery::{ use alkcall::registry::discovery::{
services_list_handler, services_list_spec, services_schema_handler, services_schema_spec, services_list_handler, services_list_spec, services_schema_handler, services_schema_spec,
@@ -887,6 +897,175 @@ mod tests {
make_handler(|input, ctx| async move { ResponseEnvelope::ok(ctx.request_id, input) }) make_handler(|input, ctx| async move { ResponseEnvelope::ok(ctx.request_id, input) })
} }
// --- Review 012 / ADR-048 amendment: ownership wiring through the
// --- HTTP gateway surface -------------------------------------------
fn namespace_acl() -> AccessControl {
AccessControl {
resource_type: Some("blob-namespace".to_string()),
resource_action: Some("read".to_string()),
..Default::default()
}
}
fn namespace_spec(name: &str) -> OperationSpec {
let mut spec = external_spec(name, namespace_acl());
spec.resource_id_path = Some("$.namespace".to_string());
spec
}
fn registry_with_namespace_gated_op() -> Arc<OperationRegistry> {
let registry = OperationRegistry::new();
registry
.register(HandlerRegistration::new(
namespace_spec("ns/stat"),
HandlerKind::Once(echo_handler()),
OperationProvenance::Local,
None,
None,
Capabilities::new(),
))
.unwrap();
Arc::new(registry)
}
fn identity_with_resources(
id: &str,
resource_type: &str,
actions: Option<Vec<&str>>,
) -> Identity {
let mut resources = HashMap::new();
if let Some(actions) = actions {
resources.insert(
resource_type.to_string(),
actions.into_iter().map(|a| a.to_string()).collect(),
);
}
Identity {
id: id.to_string(),
scopes: Vec::new(),
resources,
}
}
async fn post_call(
router: axum::Router,
token: &str,
operation: &str,
input: serde_json::Value,
) -> (axum::http::StatusCode, serde_json::Value) {
let req = Request::builder()
.method("POST")
.uri("/call")
.header("content-type", "application/json")
.header(axum::http::header::AUTHORIZATION, format!("Bearer {token}"))
.body(Body::from(
serde_json::to_vec(&json!({
"operation": operation,
"input": input,
}))
.unwrap(),
))
.unwrap();
let (status, body) = send(router, req).await;
(status, body)
}
#[tokio::test]
async fn call_resource_gated_op_without_provider_is_refused() {
let provider: Arc<dyn IdentityProvider> =
Arc::new(StaticIdentityProvider::new().with_token(
"tok-a",
identity_with_resources("token-carrier", "blob-namespace", Some(vec!["read"])),
));
let router =
build_router_with_ownership(registry_with_namespace_gated_op(), provider, None, false);
let (status, body) =
post_call(router, "tok-a", "ns/stat", json!({ "namespace": "ns-1" })).await;
assert_eq!(
status,
StatusCode::FORBIDDEN,
"the loud default refuses a resource-gated op on an unwired spine even with a static claim: {body}"
);
assert_eq!(body.get("code"), Some(&json!("FORBIDDEN")));
}
#[tokio::test]
async fn call_resource_gated_op_with_wired_provider_consults_the_live_grant_table() {
let store = Arc::new(alkcall::core::ownership::InMemoryOwnershipStore::new());
store
.record(
&identity_with_resources("tenant-a", "blob-namespace", None),
"blob-namespace",
"ns-1",
)
.await
.unwrap();
let idp: Arc<dyn IdentityProvider> = Arc::new(
StaticIdentityProvider::new()
.with_token(
"tok-a",
identity_with_resources("tenant-a", "blob-namespace", None),
)
.with_token(
"tok-claim",
identity_with_resources("token-carrier", "blob-namespace", Some(vec!["read"])),
),
);
let router = build_router_with_ownership(
registry_with_namespace_gated_op(),
Arc::clone(&idp),
Some(store),
false,
);
let (status, body) = post_call(
router.clone(),
"tok-a",
"ns/stat",
json!({ "namespace": "ns-1" }),
)
.await;
assert_eq!(
status,
StatusCode::OK,
"a live grant admits without any static claim on the identity: {body}"
);
let (status, body) = post_call(
router,
"tok-claim",
"ns/stat",
json!({ "namespace": "ns-1" }),
)
.await;
assert_eq!(
status,
StatusCode::FORBIDDEN,
"a token-carried static claim is denied without a live grant: {body}"
);
assert_eq!(body.get("code"), Some(&json!("FORBIDDEN")));
}
#[tokio::test]
async fn call_resource_gated_op_with_declared_static_fallback_serves_the_static_claim() {
let provider: Arc<dyn IdentityProvider> =
Arc::new(StaticIdentityProvider::new().with_token(
"tok-a",
identity_with_resources("token-carrier", "blob-namespace", Some(vec!["read"])),
));
let router =
build_router_with_ownership(registry_with_namespace_gated_op(), provider, None, true);
let (status, body) =
post_call(router, "tok-a", "ns/stat", json!({ "namespace": "ns-1" })).await;
assert_eq!(
status,
StatusCode::OK,
"the declared fallback keeps the ADR-011 static-claim check: {body}"
);
}
fn registry_with_echo() -> Arc<OperationRegistry> { fn registry_with_echo() -> Arc<OperationRegistry> {
let registry = OperationRegistry::new(); let registry = OperationRegistry::new();
registry registry
@@ -1126,10 +1305,21 @@ mod tests {
fn build_router( fn build_router(
registry: Arc<OperationRegistry>, registry: Arc<OperationRegistry>,
provider: Arc<dyn IdentityProvider>, provider: Arc<dyn IdentityProvider>,
) -> axum::Router {
build_router_with_ownership(registry, provider, None, false)
}
fn build_router_with_ownership(
registry: Arc<OperationRegistry>,
provider: Arc<dyn IdentityProvider>,
ownership_provider: Option<Arc<dyn alkcall::core::ownership::OwnershipProvider>>,
static_ownership_fallback: bool,
) -> axum::Router { ) -> axum::Router {
let state = RouterState { let state = RouterState {
registry: Arc::clone(&registry), registry: Arc::clone(&registry),
identity_provider: Arc::clone(&provider), identity_provider: Arc::clone(&provider),
ownership_provider,
static_ownership_fallback,
decoy: crate::server::DecoyConfig::NotFound, decoy: crate::server::DecoyConfig::NotFound,
openapi_doc: crate::server::adapter::CachedOpenAPIDoc::new(&registry), openapi_doc: crate::server::adapter::CachedOpenAPIDoc::new(&registry),
ws_sessions: Arc::new(crate::websocket::WsSessions::new()), ws_sessions: Arc::new(crate::websocket::WsSessions::new()),
+75 -75
View File
@@ -89,6 +89,8 @@ pub const RESERVED_PATHS: &[&str] = &[
pub struct HttpAdapter { pub struct HttpAdapter {
identity_provider: Arc<dyn alkcall::core::auth::IdentityProvider>, identity_provider: Arc<dyn alkcall::core::auth::IdentityProvider>,
registry: Arc<OperationRegistry>, registry: Arc<OperationRegistry>,
ownership_provider: Option<Arc<dyn alkcall::core::ownership::OwnershipProvider>>,
static_ownership_fallback: bool,
decoy: DecoyConfig, decoy: DecoyConfig,
extra_routes: Option<Router>, extra_routes: Option<Router>,
alpn: &'static [u8], alpn: &'static [u8],
@@ -133,6 +135,8 @@ impl HttpAdapter {
let state = RouterState { let state = RouterState {
registry: Arc::clone(&registry), registry: Arc::clone(&registry),
identity_provider: Arc::clone(&identity_provider), identity_provider: Arc::clone(&identity_provider),
ownership_provider: None,
static_ownership_fallback: false,
decoy: decoy.clone(), decoy: decoy.clone(),
openapi_doc: openapi_doc.clone(), openapi_doc: openapi_doc.clone(),
ws_sessions: Arc::clone(&ws_sessions), ws_sessions: Arc::clone(&ws_sessions),
@@ -145,6 +149,8 @@ impl HttpAdapter {
Self { Self {
identity_provider, identity_provider,
registry, registry,
ownership_provider: None,
static_ownership_fallback: false,
decoy, decoy,
extra_routes: None, extra_routes: None,
alpn, alpn,
@@ -162,23 +168,8 @@ impl HttpAdapter {
/// Set the decoy surface for unregistered paths and rebuild the /// Set the decoy surface for unregistered paths and rebuild the
/// router (custom routes are preserved — SRV-05). /// router (custom routes are preserved — SRV-05).
pub fn with_decoy(mut self, decoy: DecoyConfig) -> Self { pub fn with_decoy(mut self, decoy: DecoyConfig) -> Self {
self.decoy = decoy.clone(); self.decoy = decoy;
let state = RouterState { self.router = build_router(self.router_state(), self.extra_routes.clone());
registry: Arc::clone(&self.registry),
identity_provider: Arc::clone(&self.identity_provider),
decoy,
openapi_doc: self.openapi_doc.clone(),
ws_sessions: Arc::clone(&self.ws_sessions),
ws_session_slots: Arc::clone(&self.ws_session_slots),
ws_idle_timeout: self.ws_idle_timeout,
ws_openable_alpns: self.ws_openable_alpns.clone(),
ws_op_register_acl: self.ws_op_register_acl.clone(),
};
// `extra_routes` is borrowed, not consumed (SRV-05): a builder
// call after `with_extra_routes` must keep the custom routes in
// the rebuild — `with_extra_routes` stores a verified clone, so
// re-merging it is safe.
self.router = build_router(state, self.extra_routes.clone());
self self
} }
@@ -186,18 +177,11 @@ impl HttpAdapter {
/// middleware (ADR-046) and rebuild the router. Reserved paths /// middleware (ADR-046) and rebuild the router. Reserved paths
/// (`RESERVED_PATHS`) are rejected before the merge. /// (`RESERVED_PATHS`) are rejected before the merge.
pub fn with_extra_routes(mut self, routes: Router) -> Self { pub fn with_extra_routes(mut self, routes: Router) -> Self {
let state = RouterState { // `extra_routes` is borrowed, not consumed (SRV-05): a builder
registry: Arc::clone(&self.registry), // call after `with_extra_routes` must keep the custom routes in
identity_provider: Arc::clone(&self.identity_provider), // the rebuild — `with_extra_routes` stores a verified clone, so
decoy: self.decoy.clone(), // re-merging it is safe.
openapi_doc: self.openapi_doc.clone(), self.router = build_router(self.router_state(), Some(routes.clone()));
ws_sessions: Arc::clone(&self.ws_sessions),
ws_session_slots: Arc::clone(&self.ws_session_slots),
ws_idle_timeout: self.ws_idle_timeout,
ws_openable_alpns: self.ws_openable_alpns.clone(),
ws_op_register_acl: self.ws_op_register_acl.clone(),
};
self.router = build_router(state, Some(routes.clone()));
self.extra_routes = Some(routes); self.extra_routes = Some(routes);
self self
} }
@@ -211,18 +195,8 @@ impl HttpAdapter {
/// Default: [`crate::websocket::DEFAULT_WS_MAX_SESSIONS`] (64). /// Default: [`crate::websocket::DEFAULT_WS_MAX_SESSIONS`] (64).
pub fn with_ws_max_sessions(mut self, max_sessions: usize) -> Self { pub fn with_ws_max_sessions(mut self, max_sessions: usize) -> Self {
self.ws_max_sessions = max_sessions; self.ws_max_sessions = max_sessions;
let state = RouterState { self.ws_session_slots = Self::rebuild_session_slots(max_sessions);
registry: Arc::clone(&self.registry), self.router = build_router(self.router_state(), self.extra_routes.clone());
identity_provider: Arc::clone(&self.identity_provider),
decoy: self.decoy.clone(),
openapi_doc: self.openapi_doc.clone(),
ws_sessions: Arc::clone(&self.ws_sessions),
ws_session_slots: Self::rebuild_session_slots(max_sessions),
ws_idle_timeout: self.ws_idle_timeout,
ws_openable_alpns: self.ws_openable_alpns.clone(),
ws_op_register_acl: self.ws_op_register_acl.clone(),
};
self.router = build_router(state, self.extra_routes.clone());
self self
} }
@@ -244,17 +218,7 @@ impl HttpAdapter {
/// Default: [`crate::websocket::DEFAULT_WS_IDLE_TIMEOUT`] (60 s). /// Default: [`crate::websocket::DEFAULT_WS_IDLE_TIMEOUT`] (60 s).
pub fn with_ws_idle_timeout(mut self, idle_timeout: Option<Duration>) -> Self { pub fn with_ws_idle_timeout(mut self, idle_timeout: Option<Duration>) -> Self {
self.ws_idle_timeout = idle_timeout; self.ws_idle_timeout = idle_timeout;
let state = RouterState { let state = self.router_state();
registry: Arc::clone(&self.registry),
identity_provider: Arc::clone(&self.identity_provider),
decoy: self.decoy.clone(),
openapi_doc: self.openapi_doc.clone(),
ws_sessions: Arc::clone(&self.ws_sessions),
ws_session_slots: Arc::clone(&self.ws_session_slots),
ws_idle_timeout: self.ws_idle_timeout,
ws_openable_alpns: self.ws_openable_alpns.clone(),
ws_op_register_acl: self.ws_op_register_acl.clone(),
};
self.router = build_router(state, self.extra_routes.clone()); self.router = build_router(state, self.extra_routes.clone());
self self
} }
@@ -296,17 +260,7 @@ impl HttpAdapter {
openables: Vec<crate::websocket::OpenableAlpn>, openables: Vec<crate::websocket::OpenableAlpn>,
) -> Self { ) -> Self {
self.ws_openable_alpns = Some(openables.into()); self.ws_openable_alpns = Some(openables.into());
let state = RouterState { let state = self.router_state();
registry: Arc::clone(&self.registry),
identity_provider: Arc::clone(&self.identity_provider),
decoy: self.decoy.clone(),
openapi_doc: self.openapi_doc.clone(),
ws_sessions: Arc::clone(&self.ws_sessions),
ws_session_slots: Arc::clone(&self.ws_session_slots),
ws_idle_timeout: self.ws_idle_timeout,
ws_openable_alpns: self.ws_openable_alpns.clone(),
ws_op_register_acl: self.ws_op_register_acl.clone(),
};
self.router = build_router(state, self.extra_routes.clone()); self.router = build_router(state, self.extra_routes.clone());
self self
} }
@@ -327,17 +281,7 @@ impl HttpAdapter {
/// extension instead. /// extension instead.
pub fn with_ws_op_register_acl(mut self, acl: alkcall::registry::spec::AccessControl) -> Self { pub fn with_ws_op_register_acl(mut self, acl: alkcall::registry::spec::AccessControl) -> Self {
self.ws_op_register_acl = acl; self.ws_op_register_acl = acl;
let state = RouterState { let state = self.router_state();
registry: Arc::clone(&self.registry),
identity_provider: Arc::clone(&self.identity_provider),
decoy: self.decoy.clone(),
openapi_doc: self.openapi_doc.clone(),
ws_sessions: Arc::clone(&self.ws_sessions),
ws_session_slots: Arc::clone(&self.ws_session_slots),
ws_idle_timeout: self.ws_idle_timeout,
ws_openable_alpns: self.ws_openable_alpns.clone(),
ws_op_register_acl: self.ws_op_register_acl.clone(),
};
self.router = build_router(state, self.extra_routes.clone()); self.router = build_router(state, self.extra_routes.clone());
self self
} }
@@ -348,6 +292,60 @@ impl HttpAdapter {
Arc::new(tokio::sync::Semaphore::new(max_sessions)) Arc::new(tokio::sync::Semaphore::new(max_sessions))
} }
/// The router state assembled from the adapter's current
/// configuration; every builder's rebuild goes through here.
fn router_state(&self) -> RouterState {
RouterState {
registry: Arc::clone(&self.registry),
identity_provider: Arc::clone(&self.identity_provider),
ownership_provider: self.ownership_provider.clone(),
static_ownership_fallback: self.static_ownership_fallback,
decoy: self.decoy.clone(),
openapi_doc: self.openapi_doc.clone(),
ws_sessions: Arc::clone(&self.ws_sessions),
ws_session_slots: Arc::clone(&self.ws_session_slots),
ws_idle_timeout: self.ws_idle_timeout,
ws_openable_alpns: self.ws_openable_alpns.clone(),
ws_op_register_acl: self.ws_op_register_acl.clone(),
}
}
/// Wire the ownership provider (alkcall 0.10 review 012 / ADR-048
/// amendment) every dispatch spine this adapter serves consults —
/// the gateway endpoints' spine and the MCP nest's spine alike.
/// The door contract applies unchanged: any HTTP-door deployment
/// serving `resource_type`-gated ops through the gateway or MCP
/// surface must wire the same provider the native wire path
/// consults (visible-surface = authorized-surface). Without a
/// provider (or a declared [`Self::with_static_ownership_fallback`])
/// a `resource_type`-gated op served over HTTP is refused with a
/// `FORBIDDEN` envelope — the spine's loud default.
///
/// Default: `None` (no provider wired).
pub fn with_ownership_provider(
mut self,
provider: Arc<dyn alkcall::core::ownership::OwnershipProvider>,
) -> Self {
self.ownership_provider = Some(provider);
self.router = build_router(self.router_state(), self.extra_routes.clone());
self
}
/// Declare the static `Identity.resources` fallback
/// ([`Self::with_ownership_provider`]'s doc for the disposition
/// space): ops whose `access_control` gates on a `resource_type`
/// are served on the HTTP surface through the token-carried static
/// claims instead of being refused for the missing provider wiring.
/// A code-review-visible declaration of "no runtime ownership model
/// here," never the accidental default.
///
/// Default: `false` (the loud default is active).
pub fn with_static_ownership_fallback(mut self) -> Self {
self.static_ownership_fallback = true;
self.router = build_router(self.router_state(), self.extra_routes.clone());
self
}
/// The configured decoy surface (assembly introspection). /// The configured decoy surface (assembly introspection).
pub fn decoy(&self) -> &DecoyConfig { pub fn decoy(&self) -> &DecoyConfig {
&self.decoy &self.decoy
@@ -369,7 +367,7 @@ fn build_router(state: RouterState, extra_routes: Option<Router>) -> Router {
#[cfg(feature = "mcp")] #[cfg(feature = "mcp")]
let mcp_router: Router<RouterState> = { let mcp_router: Router<RouterState> = {
let dispatch = crate::gateway::GatewayDispatch::new(Arc::clone(&state.registry)); let dispatch = state.dispatch();
Router::new() Router::new()
.nest_service( .nest_service(
"/mcp", "/mcp",
@@ -1235,6 +1233,8 @@ mod tests {
RouterState { RouterState {
registry: empty_registry(), registry: empty_registry(),
identity_provider: idp, identity_provider: idp,
ownership_provider: None,
static_ownership_fallback: false,
decoy: DecoyConfig::default(), decoy: DecoyConfig::default(),
openapi_doc: CachedOpenAPIDoc::new(&OperationRegistry::new()), openapi_doc: CachedOpenAPIDoc::new(&OperationRegistry::new()),
ws_sessions: Arc::new(crate::websocket::WsSessions::new()), ws_sessions: Arc::new(crate::websocket::WsSessions::new()),
+24
View File
@@ -6,6 +6,12 @@ use std::sync::Arc;
use alkcall::core::auth::IdentityProvider; use alkcall::core::auth::IdentityProvider;
use alkcall::registry::registration::OperationRegistry; use alkcall::registry::registration::OperationRegistry;
#[cfg(feature = "mcp")]
use axum::extract::FromRef;
#[cfg(feature = "mcp")]
use crate::gateway::GatewayState;
/// The stealth decoy surface for paths that are not registered /// The stealth decoy surface for paths that are not registered
/// operations and not part of the reserved default surface (the 6 /// operations and not part of the reserved default surface (the 6
/// gateway endpoints, `/healthz`, `/openapi.json`, the MCP route, the /// gateway endpoints, `/healthz`, `/openapi.json`, the MCP route, the
@@ -39,6 +45,12 @@ pub enum DecoyConfig {
pub(crate) struct RouterState { pub(crate) struct RouterState {
pub(crate) registry: Arc<OperationRegistry>, pub(crate) registry: Arc<OperationRegistry>,
pub(crate) identity_provider: Arc<dyn IdentityProvider>, pub(crate) identity_provider: Arc<dyn IdentityProvider>,
/// The ownership provider threaded into every dispatch spine this
/// state serves (gateway routes and the MCP nest). `None` + no
/// static-fallback declaration means resource-gated ops are refused
/// by the spine's loud default.
pub(crate) ownership_provider: Option<Arc<dyn alkcall::core::ownership::OwnershipProvider>>,
pub(crate) static_ownership_fallback: bool,
pub(crate) decoy: DecoyConfig, pub(crate) decoy: DecoyConfig,
pub(crate) openapi_doc: crate::server::adapter::CachedOpenAPIDoc, pub(crate) openapi_doc: crate::server::adapter::CachedOpenAPIDoc,
pub(crate) ws_sessions: Arc<crate::websocket::WsSessions>, pub(crate) ws_sessions: Arc<crate::websocket::WsSessions>,
@@ -79,6 +91,16 @@ impl axum::extract::FromRef<RouterState> for DecoyConfig {
} }
} }
impl RouterState {
/// A dispatch spine wired with this state's ownership configuration
/// (the MCP nest's builder; the gateway routes extract
/// [`GatewayState`] via `FromRef`).
#[cfg(feature = "mcp")]
pub(crate) fn dispatch(&self) -> crate::gateway::GatewayDispatch {
GatewayState::from_ref(self).dispatch()
}
}
impl axum::extract::FromRef<RouterState> for crate::server::adapter::CachedOpenAPIDoc { impl axum::extract::FromRef<RouterState> for crate::server::adapter::CachedOpenAPIDoc {
fn from_ref(state: &RouterState) -> Self { fn from_ref(state: &RouterState) -> Self {
state.openapi_doc.clone() state.openapi_doc.clone()
@@ -99,6 +121,8 @@ mod tests {
let state = RouterState { let state = RouterState {
registry: Arc::new(OperationRegistry::new()), registry: Arc::new(OperationRegistry::new()),
identity_provider: Arc::new(NoopProvider), identity_provider: Arc::new(NoopProvider),
ownership_provider: None,
static_ownership_fallback: false,
decoy: DecoyConfig::Redirect { decoy: DecoyConfig::Redirect {
to: "https://example.com".to_string(), to: "https://example.com".to_string(),
}, },