Review 007 Unit 2 (the two "implement" decisions taken during remediation, plus the coverage gap): - WS-29: the `op/register` override surface review-006 UP-02 and ADR-048 recorded as landed is now implemented. The hook threads an `op_register_acl` `AccessControl` into `op_register_spec` (the permissive `AccessControl::default()` remains the default everywhere); the built-in surface sets it via `HttpAdapter::with_ws_op_register_acl`, bare-registry/custom routes via the `OpRegisterAcl` request extension (mirroring `ChannelsPolicy`/`WsTimeouts`/`OpenableAlpns`). A peer whose identity does not satisfy the ACL gets `FORBIDDEN` on the announce. Gates: builder path (`FORBIDDEN` scope-less / announce-ok scoped) + extension path. - WS-30: the bare-registry `SessionState` is built by `FromRef` per request, so its default-cap semaphore bounds nothing across requests (corrects review-002 WS-17's "bounded at 64 sessions" claim). New `SessionSlots` request extension carries the shared semaphore for routes that need an effective cap; the upgrade handler prefers it over the state value. Doc comments corrected (`SessionState`, `WsTimeouts`, `ws_upgrade_handler`). Gate: cap-1 route → 503 over cap → slot freed on session end. - WS-32: the built-in openables threading (`with_ws_openable_alpns` → `RouterState` → `SessionState` → hook) gets its first gate — every Unit-3 gate rode the `OpenableAlpns` extension fallback. `builder_path_openables_serve_the_data_channel_ surface` discovers the openable via `services/list`, opens the channel, and round-trips bytes through the builder-built router. Verification: cargo test 454 passed / 0 failed; cargo test --all-features 587 passed / 0 failed (+5 gates); clippy (both configs) clean; fmt clean. Review: docs/reviews/007-ws-data-channel-surface-review.md
131 lines
5.1 KiB
Rust
131 lines
5.1 KiB
Rust
//! Shared server state and configuration for the `HttpAdapter` router.
|
|
|
|
use std::path::PathBuf;
|
|
use std::sync::Arc;
|
|
|
|
use alkcall::core::auth::IdentityProvider;
|
|
use alkcall::registry::registration::OperationRegistry;
|
|
|
|
/// The stealth decoy surface for paths that are not registered
|
|
/// operations and not part of the reserved default surface (the 6
|
|
/// gateway endpoints, `/healthz`, `/openapi.json`, the MCP route, the
|
|
/// WS upgrade path). Set by the assembly layer at `HttpAdapter`
|
|
/// construction. The existence of the decoy path is fixed by ADR-010;
|
|
/// the variant is a two-way-door config default.
|
|
#[derive(Clone, Default, Debug)]
|
|
pub enum DecoyConfig {
|
|
/// Serve a fake `404 Not Found` (the default — a fake nginx 404).
|
|
#[default]
|
|
NotFound,
|
|
/// Serve a static site from the given directory root.
|
|
StaticSite {
|
|
/// The directory tree served for unregistered paths.
|
|
root: PathBuf,
|
|
},
|
|
/// Redirect unregistered paths to the given URL.
|
|
Redirect {
|
|
/// The redirect target URL.
|
|
to: String,
|
|
},
|
|
}
|
|
|
|
/// State embedded in the axum `Router`: the registry and identity
|
|
/// provider every request handler reaches through the router state, plus
|
|
/// the decoy config for the fallback, the pre-serialized
|
|
/// `/openapi.json` projection cache (SRV-09), and the WS session
|
|
/// lifecycle config the upgrade handler enforces: the shared pump-handle
|
|
/// registry (WS-08) and the concurrent-session cap (WS-09).
|
|
#[derive(Clone)]
|
|
pub(crate) struct RouterState {
|
|
pub(crate) registry: Arc<OperationRegistry>,
|
|
pub(crate) identity_provider: Arc<dyn IdentityProvider>,
|
|
pub(crate) decoy: DecoyConfig,
|
|
pub(crate) openapi_doc: crate::server::adapter::CachedOpenAPIDoc,
|
|
pub(crate) ws_sessions: Arc<crate::websocket::WsSessions>,
|
|
/// Session cap (WS-09): one `HttpAdapter`-wide semaphore, built
|
|
/// once at construction — the upgrade handler acquires one permit
|
|
/// per upgrade and holds it for the session's lifetime.
|
|
pub(crate) ws_session_slots: Arc<tokio::sync::Semaphore>,
|
|
/// Idle-read timeout for the WS pumps (WS-01); `None` disables.
|
|
pub(crate) ws_idle_timeout: Option<std::time::Duration>,
|
|
/// The openable-ALPN set for WS sessions (WS-22): the per-ALPN
|
|
/// open-op specs + handlers registered on each session's fork.
|
|
/// `None` (the default) declares no openables — a WS session then
|
|
/// carries no data-channel open ops (the pre-Unit-2 shape).
|
|
pub(crate) ws_openable_alpns: Option<Arc<[crate::websocket::OpenableAlpn]>>,
|
|
/// The `op/register` surface's `AccessControl` for WS sessions
|
|
/// (review 007 WS-29): the per-session announce op is registered
|
|
/// with this ACL. Default: `AccessControl::default()` (the
|
|
/// permissive crate default).
|
|
pub(crate) ws_op_register_acl: alkcall::registry::spec::AccessControl,
|
|
}
|
|
|
|
impl axum::extract::FromRef<RouterState> for crate::websocket::SessionState {
|
|
fn from_ref(state: &RouterState) -> Self {
|
|
crate::websocket::SessionState::new(
|
|
Arc::clone(&state.registry),
|
|
Arc::clone(&state.ws_sessions),
|
|
Arc::clone(&state.ws_session_slots),
|
|
state.ws_idle_timeout,
|
|
state.ws_openable_alpns.clone(),
|
|
state.ws_op_register_acl.clone(),
|
|
)
|
|
}
|
|
}
|
|
|
|
impl axum::extract::FromRef<RouterState> for DecoyConfig {
|
|
fn from_ref(state: &RouterState) -> Self {
|
|
state.decoy.clone()
|
|
}
|
|
}
|
|
|
|
impl axum::extract::FromRef<RouterState> for crate::server::adapter::CachedOpenAPIDoc {
|
|
fn from_ref(state: &RouterState) -> Self {
|
|
state.openapi_doc.clone()
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn decoy_config_default_is_not_found() {
|
|
assert!(matches!(DecoyConfig::default(), DecoyConfig::NotFound));
|
|
}
|
|
|
|
#[test]
|
|
fn router_state_from_ref_extracts_decoy() {
|
|
let state = RouterState {
|
|
registry: Arc::new(OperationRegistry::new()),
|
|
identity_provider: Arc::new(NoopProvider),
|
|
decoy: DecoyConfig::Redirect {
|
|
to: "https://example.com".to_string(),
|
|
},
|
|
openapi_doc: crate::server::adapter::CachedOpenAPIDoc::new(&OperationRegistry::new()),
|
|
ws_sessions: Arc::new(crate::websocket::WsSessions::new()),
|
|
ws_session_slots: Arc::new(tokio::sync::Semaphore::new(
|
|
crate::websocket::DEFAULT_WS_MAX_SESSIONS,
|
|
)),
|
|
ws_idle_timeout: Some(crate::websocket::DEFAULT_WS_IDLE_TIMEOUT),
|
|
ws_openable_alpns: None,
|
|
ws_op_register_acl: alkcall::registry::spec::AccessControl::default(),
|
|
};
|
|
let extracted: DecoyConfig = axum::extract::FromRef::from_ref(&state);
|
|
assert!(matches!(extracted, DecoyConfig::Redirect { .. }));
|
|
}
|
|
|
|
struct NoopProvider;
|
|
impl IdentityProvider for NoopProvider {
|
|
fn resolve_from_fingerprint(&self, _: &str) -> Option<alkcall::core::auth::Identity> {
|
|
None
|
|
}
|
|
fn resolve_from_token(
|
|
&self,
|
|
_: &alkcall::core::auth::AuthToken,
|
|
) -> Option<alkcall::core::auth::Identity> {
|
|
None
|
|
}
|
|
}
|
|
}
|