Files
alkhttp/src/server/state.rs
T
glm-5.3-flash 24c2e9a224 feat(websocket): WS-29/30/32 — op/register ACL surface, SessionSlots, builder-path gate
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
2026-09-05 05:44:28 +00:00

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
}
}
}