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:
1 parent
1cac63fdba
commit
ad63507124
11 files changed
+435
-93
No files matched your search
@@ -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
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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)
|
||||||
Generated
+6
-6
@@ -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]]
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
@@ -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(®istry),
|
registry: Arc::clone(®istry),
|
||||||
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(®istry),
|
openapi_doc: crate::server::adapter::CachedOpenAPIDoc::new(®istry),
|
||||||
ws_sessions: Arc::new(crate::websocket::WsSessions::new()),
|
ws_sessions: Arc::new(crate::websocket::WsSessions::new()),
|
||||||
|
|||||||
+75
-75
@@ -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(®istry),
|
registry: Arc::clone(®istry),
|
||||||
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()),
|
||||||
|
|||||||
@@ -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(),
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in new issue
Block a user