refactor(gateway): migrate to alkcall 0.2 promoted gateway module
Bump the alkcall dependency to 0.2 (with the gateway feature) and converge on the promoted shared pieces: - The local dispatch spine (gateway/dispatch.rs, 721 lines) is deleted; GatewayDispatch, schema_disclosure_denial, and DEFAULT_DEADLINE are re-exported from alkcall::gateway (alkcall ADR-048). The 30 s default deadline preserves the previous behavior exactly. - gateway/schema_cache.rs (PublishSchemaCache) is deleted: alkcall CF-003 compiles publish_schema at registration time and exposes OperationRegistry::publish_validator; the /publish chunk stream resolves against it. Un-compilable schemas are now rejected at registration, so the two end-to-end fail-closed tests were reworked into a registration-rejection test (a stronger guarantee). - schema_disclosure_denial consumers (to_mcp, routes) use alkcall's promoted implementation; the alkhttp-local copy is gone (ADR-071 updated: the guard stays as defense-in-depth, the implementation no longer forks). - CF-001: from_wss drop monitor and the WS overlay tests use CallError::connection_closed; the review-001-ws-eof-signal race tests now assert retryable CONNECTION_CLOSED on both resolution paths (the tolerated non-retryable INTERNAL write-failure outcome is gone). - Added CHANGELOG.md (Keep a Changelog), Unreleased section records the bump and convergence. Verification: cargo test default 453 ok, wss 470 ok, mcp 526 ok, all-features 575 ok; clippy -D warnings clean (default + all-features, all-targets); fmt clean; cargo doc warning-free. Net: -1093 lines.
This commit is contained in:
46
CHANGELOG.md
Normal file
46
CHANGELOG.md
Normal file
@@ -0,0 +1,46 @@
|
||||
# Changelog
|
||||
|
||||
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
|
||||
this crate adheres to [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Changed
|
||||
|
||||
- **`alkcall` dependency bumped to 0.2** with the `gateway` feature
|
||||
enabled. The transport-neutral dispatch spine now comes from
|
||||
`alkcall::gateway` (promoted from this crate per alkcall ADR-048):
|
||||
`GatewayDispatch`, `schema_disclosure_denial`, and
|
||||
`DEFAULT_DEADLINE` are re-exported from `alkhttp::gateway`; the
|
||||
local copy (`gateway/dispatch.rs`) was deleted. The spine is used
|
||||
with the 30 s default deadline (identical behavior to the local
|
||||
version). alkcall 0.2 also carries the consumer-findings fixes
|
||||
CF-001..004 documented in
|
||||
`alkcall/docs/reviews/consumer-findings-ledger.md`.
|
||||
- **`/publish` chunk validation uses the registry-owned validator**
|
||||
(CF-003). The local compile-once `PublishSchemaCache`
|
||||
(`gateway/schema_cache.rs`) is removed — `publish_schema` is
|
||||
compiled at **registration time** by alkcall and exposed as
|
||||
`OperationRegistry::publish_validator`; an un-compilable schema is
|
||||
now a registration error, so the fail-open window this cache closed
|
||||
compiled at **registration time** by alkcall and exposed as
|
||||
`OperationRegistry::publish_validator`; an un-compilable schema is
|
||||
now a registration error, so the fail-open window this cache closed
|
||||
GW-side cannot exist upstream either. The `/publish` chunk stream
|
||||
resolves against the registry's cached validator.
|
||||
- The from_wss drop monitor and the WS overlay tests use
|
||||
`CallError::connection_closed` (CF-001), and the
|
||||
review-001-ws-eof-signal race tests now assert retryable
|
||||
`CONNECTION_CLOSED` on **both** resolution paths (the drop monitor
|
||||
and the undelivered-request write failure) — the previously
|
||||
tolerated non-retryable `INTERNAL: failed to write request frame`
|
||||
outcome on the race path is gone.
|
||||
|
||||
### Notes
|
||||
|
||||
- **CF-004 convergence:** the alkhttp-local `services/schema`
|
||||
defense-in-depth guard remains, but its shared check
|
||||
(`schema_disclosure_denial`) is now alkcall's promoted
|
||||
implementation (ADR-048); the alkhttp-local copy is gone. See
|
||||
ADR-071 for the updated disposition.
|
||||
4
Cargo.lock
generated
4
Cargo.lock
generated
@@ -27,9 +27,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "alkcall"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "49664a13ea571d7a6e59c9ed52d165dfdfe389c9a93bb301cc1fb86f29bae8bd"
|
||||
checksum = "83e91782140beee66656cda7c5bfb1248f76302225b51538d3523b51d178ac7d"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"bytes",
|
||||
|
||||
@@ -22,12 +22,13 @@ h2 = ["dep:hyper", "hyper-util/http2", "hyper/http2"]
|
||||
http1 = ["dep:hyper", "hyper-util/http1", "hyper/http1"]
|
||||
|
||||
[dependencies]
|
||||
alkcall = "0.1.1"
|
||||
alkcall = { version = "0.2", features = ["gateway"] }
|
||||
arc-swap = "1"
|
||||
axum = { version = "0.8", features = ["ws"] }
|
||||
hyper = { version = "1", optional = true, features = ["server"] }
|
||||
hyper-util = { version = "0.1", features = ["server", "service", "tokio"] }
|
||||
httpdate = "1"
|
||||
jsonschema = { version = "0.46", default-features = false }
|
||||
reqwest = { version = "0.13", default-features = false, features = ["json", "stream", "rustls"] }
|
||||
reqwest-middleware = "0.5"
|
||||
reqwest-retry = "0.9"
|
||||
@@ -45,7 +46,6 @@ http = "1"
|
||||
http-body-util = "0.1"
|
||||
url = "2"
|
||||
percent-encoding = "2"
|
||||
jsonschema = { version = "0.46", default-features = false }
|
||||
parking_lot = "0.12"
|
||||
rmcp = { version = "1.8", optional = true, default-features = false, features = [
|
||||
"client",
|
||||
|
||||
@@ -68,7 +68,7 @@ types — the former `alknet-core` and `alknet-call` merged).
|
||||
| [068](decisions/068-gateway-publish-endpoint.md) | Gateway `/publish` Endpoint | 6th gateway endpoint for `OperationType::Pub` (producer→consumer streaming); NDJSON request body → `call.published` chunks |
|
||||
| [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 |
|
||||
| [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 |
|
||||
| [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) |
|
||||
|
||||
## Relevant Open Questions
|
||||
|
||||
|
||||
@@ -53,23 +53,53 @@ registry rejects non-`Pub` operations before the handler runs and the
|
||||
sink ignores the input, so nothing is projected there.
|
||||
|
||||
The visibility + ACL check itself is one shared function
|
||||
(`gateway::dispatch::schema_disclosure_denial`) used by the HTTP GET
|
||||
`/schema` route, the dispatch-spine guard, and the MCP `schema` tool,
|
||||
so the transports cannot drift.
|
||||
(`schema_disclosure_denial`) used by the HTTP GET `/schema` route, the
|
||||
dispatch-spine guard, and the MCP `schema` tool, so the transports
|
||||
cannot drift.
|
||||
|
||||
## Consequences
|
||||
|
||||
- **Transport-level invariant (PRJ-16's wording):** no alkhttp
|
||||
transport can fetch, through any path, a spec the GET `/schema`
|
||||
route would deny for the same identity.
|
||||
- **When CF-004 lands, the guard stays.** The alkcall-side handler
|
||||
check is the complete fix for every transport (wire, overlays, peer
|
||||
composition); this per-transport check remains as defense-in-depth.
|
||||
Do not remove it.
|
||||
- Wire-observable behavior changes only for the previously-leaking
|
||||
requests: they now get the same 404/403 the GET route returns.
|
||||
Legitimate `services/schema` calls (allowed inner names) are
|
||||
unaffected.
|
||||
- **When CF-004 landed, the guard stayed — and was promoted.** The
|
||||
alkcall-side handler check is the complete fix for every transport
|
||||
(wire, overlays, peer composition; alkcall commit `8cb2a6e`). The
|
||||
alkhttp spine, the GET `/schema` route, the MCP `schema` tool, and
|
||||
the spine's `services/schema` op-path guard all now converge on the
|
||||
**shared promoted implementation**: `alkcall::gateway::
|
||||
schema_disclosure_denial` (alkcall ADR-048). The local copy was
|
||||
deleted on the alkcall-0.2 bump — one implementation, so the
|
||||
transports still cannot drift, and no alkhttp-local fork remains.
|
||||
Do not re-introduce a local copy.
|
||||
- **The dispatch spine itself is promoted too.** `GatewayDispatch`
|
||||
(the `invoke`/`invoke_streaming`/`invoke_sink` spine with the
|
||||
root-`OperationContext` invariants and the inner-`name` guard) is
|
||||
`alkcall::gateway::GatewayDispatch` (feature `gateway`, enabled
|
||||
unconditionally by the alkcall dependency — the gateway endpoints
|
||||
are this crate's sole invoke path, ADR-047). The alkhttp-local copy
|
||||
was deleted; only the HTTP-specific layers remain here (the
|
||||
CallError → HTTP mapping in `gateway::error`, NDJSON/SSE framing,
|
||||
body caps, batch envelopes, and this ADR's original
|
||||
FORBIDDEN-vs-spec-404 split on the GET route).
|
||||
- **`/publish` chunk validation is registry-backed.** The local
|
||||
compile-once `PublishSchemaCache` was deleted: alkcall CF-003
|
||||
compiles `publish_schema` at registration time and exposes the
|
||||
cached validator as `OperationRegistry::publish_validator`. The
|
||||
`/publish` chunk stream resolves against that cache; the
|
||||
fail-closed guarantee moved upstream with it (an un-compilable
|
||||
schema is a registration error, so an unvalidated-ingest path
|
||||
cannot be constructed).
|
||||
- **CF-001 resolution:** alkcall's `CallError::connection_closed`
|
||||
(retryable `CONNECTION_CLOSED`) covers the write-failure race; the
|
||||
from_wss drop-monitor race tests assert retryable-only (the
|
||||
previously-tolerated non-retryable `INTERNAL: failed to write
|
||||
request frame` outcome is gone from the wire vocabulary on the
|
||||
undelivered-request path).
|
||||
- Wire-observable behavior is unchanged from the pre-promotion guard:
|
||||
previously-leaking requests still get the same 404/403 the GET
|
||||
route returns; legitimate `services/schema` calls (allowed inner
|
||||
names) are unaffected.
|
||||
- The guard matches the outer registration's `spec.name` against the
|
||||
`services/schema` constant rather than the raw request string, so
|
||||
leading-slash variants (`/services/schema`) hit the same check.
|
||||
@@ -33,7 +33,8 @@
|
||||
//! fails in-flight calls retryable: alkcall's client-side read pump only
|
||||
//! routes envelopes and does not observe EOF, so the adapter owns drop
|
||||
//! semantics — the [`WssSession`] monitor awaits the WS read pump's EOF
|
||||
//! signal and fails all pending calls with retryable `CONNECTION_CLOSED`.
|
||||
//! signal and fails all pending calls with retryable `CONNECTION_CLOSED`
|
||||
//! (alkcall's `CallError::connection_closed`, CF-001).
|
||||
//! The EOF signal is lossless (a retained watch value, WS-02): EOF is
|
||||
//! observed even if it fires before the monitor starts, or after the
|
||||
//! session was forgotten (fire-and-forget import). Once EOF has been
|
||||
@@ -86,7 +87,7 @@ const PENDING_SWEEP_INTERVAL: std::time::Duration = std::time::Duration::from_mi
|
||||
const PENDING_SWEEP_MAX_POST_EOF: u32 = 8;
|
||||
|
||||
fn connection_closed_error() -> CallError {
|
||||
CallError::new("CONNECTION_CLOSED", "from_wss connection dropped", true)
|
||||
CallError::connection_closed("from_wss connection dropped")
|
||||
}
|
||||
|
||||
/// The `from_wss` consumer adapter (wss feature): dials a remote WS
|
||||
@@ -978,15 +979,16 @@ mod tests {
|
||||
.expect("join");
|
||||
match response.result {
|
||||
Err(e) => {
|
||||
if e.code == "INTERNAL" && e.message.contains("failed to write request frame") {
|
||||
// The call registered but the mux was torn down
|
||||
// before the request frame was written — the
|
||||
// write-failure path resolved it (no hang; the
|
||||
// retryable variant is asserted by the sweep test
|
||||
// and the pre-drop test).
|
||||
} else {
|
||||
assert!(e.retryable, "drop error must be retryable, got {e:?}");
|
||||
}
|
||||
assert!(
|
||||
e.retryable,
|
||||
"drop error must be retryable regardless of whether the call resolved via \
|
||||
the drop monitor (CONNECTION_CLOSED) or the CF-001 write-failure mapping \
|
||||
(also CONNECTION_CLOSED post-CF-001), got {e:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
e.code, "CONNECTION_CLOSED",
|
||||
"both resolution paths share the retryable wire code post-CF-001: {e:?}"
|
||||
);
|
||||
}
|
||||
Ok(_) => panic!("expected Err after connection drop"),
|
||||
}
|
||||
|
||||
@@ -292,15 +292,15 @@ fn json_type_name(value: &Value) -> &'static str {
|
||||
/// invisible (NOT_FOUND regardless of caller), ACL-forbidden ops
|
||||
/// return FORBIDDEN. The MCP `schema` tool must never return the full
|
||||
/// spec of an op the caller could not call. The check is the shared
|
||||
/// `schema_disclosure_denial` (review-002 PRJ-16) so the HTTP route,
|
||||
/// the dispatch-spine `services/schema` guard, and this tool cannot
|
||||
/// drift.
|
||||
/// `schema_disclosure_denial` (review-002 PRJ-16, promoted to alkcall
|
||||
/// ADR-048) so the HTTP route, the dispatch-spine `services/schema`
|
||||
/// guard, and this tool cannot drift.
|
||||
fn schema_visibility_and_access_denial(
|
||||
registry: &OperationRegistry,
|
||||
operation: &str,
|
||||
identity: Option<&Identity>,
|
||||
) -> Option<CallError> {
|
||||
crate::gateway::schema_disclosure_denial(registry, operation, identity)
|
||||
alkcall::gateway::schema_disclosure_denial(registry, operation, identity)
|
||||
}
|
||||
|
||||
fn map_search_response(response: ResponseEnvelope, query: Option<&str>) -> CallToolResult {
|
||||
|
||||
@@ -1,721 +0,0 @@
|
||||
//! Shared dispatch spine for the `to_openapi` / `to_mcp` gateway
|
||||
//! projections.
|
||||
//!
|
||||
//! Thin concrete struct (not a trait — the alknet research ruled out a
|
||||
//! trait with an associated output type). Holds `Arc<OperationRegistry>`
|
||||
//! and exposes an `invoke()` family returning the neutral
|
||||
//! `ResponseEnvelope` — identity is supplied per-call, resolved upstream
|
||||
//! in the auth middleware. Each
|
||||
//! gateway maps the envelope to its own wire shape (`to_openapi` → HTTP
|
||||
//! `Response`, `to_mcp` → `CallToolResult`).
|
||||
//!
|
||||
//! # Security invariants
|
||||
//!
|
||||
//! - `internal: false` — ACL runs against the caller's `identity`, not a
|
||||
//! handler's composition authority (alkcall ADR-017).
|
||||
//! - `forwarded_for: None` — wire-ingress only.
|
||||
//!
|
||||
//! The root `OperationContext` is constructed identically for both
|
||||
//! gateways, making them provably identical on the security axis (auth,
|
||||
//! authority, ACL); they diverge only on wire-framing.
|
||||
//!
|
||||
//! # Deadline
|
||||
//!
|
||||
//! Once-op invokes ([`GatewayDispatch::invoke`]) and sink invokes
|
||||
//! ([`GatewayDispatch::invoke_sink`]; GW-17) are bounded by
|
||||
//! `DEFAULT_TIMEOUT` (30 s): the registry invoke is wrapped in
|
||||
//! `tokio::time::timeout` and a hung handler surfaces as a `TIMEOUT`
|
||||
//! error envelope (`504` under the gateway's error mapping), not an
|
||||
//! indefinitely-held HTTP request. The sink wrapper bounds the whole
|
||||
//! dispatch — chunk upload pacing and the handler's final-completion
|
||||
//! await alike — so the final envelope always arrives (or the deadline
|
||||
//! trips) within the window http-server.md documents. Streaming
|
||||
//! dispatch sets `deadline: None` (subscriptions are unbounded per
|
||||
//! alkcall ADR-021).
|
||||
//!
|
||||
//! # The `services/schema` op-path guard (review-002 PRJ-16)
|
||||
//!
|
||||
//! The registry-level pre-checks fire on the **outer** operation name
|
||||
//! only, but `services/schema` is an External op whose *input* names
|
||||
//! another operation — and the underlying handler does a bare
|
||||
//! `registry.registration(name)` → spec projection with no visibility
|
||||
//! or AccessControl check of its own (alkcall CF-004). Without a guard,
|
||||
//! `POST /call {"operation":"services/schema","input":{"name":X}}`
|
||||
//! returns the full spec of any Internal op, defeating the GET
|
||||
//! `/schema` fix (GW-02/SRV-02) through the op path.
|
||||
//! [`GatewayDispatch::invoke`] and [`GatewayDispatch::invoke_streaming`]
|
||||
//! therefore apply the same is-internal + access-control checks to the
|
||||
//! inner `name` that GET `/schema` applies
|
||||
//! (404 for Internal, FORBIDDEN for ACL denial) before dispatching.
|
||||
//! This is the alkhttp-local layer of the fix; the complete fix is the
|
||||
//! alkcall-side handler check (CF-004,
|
||||
//! `alkcall/docs/reviews/consumer-findings-ledger.md`). When CF-004
|
||||
//! lands, this guard remains as defense-in-depth: the per-transport
|
||||
//! check stays so no alkhttp transport can fetch a spec the GET
|
||||
//! `/schema` route would deny for the same identity.
|
||||
//!
|
||||
//! Sink dispatch (`invoke_sink`, the `/publish` path) cannot reach
|
||||
//! `services/schema`: the registry rejects non-`Pub` operations before
|
||||
//! the handler runs, and the sink handler never reads the input, so
|
||||
//! no spec is projected there.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use alkcall::core::auth::Identity;
|
||||
use alkcall::core::types::Capabilities;
|
||||
use alkcall::protocol::wire::{CallError, ResponseEnvelope};
|
||||
use alkcall::registry::context::{AbortPolicy, OperationContext, ScopedPeerEnv};
|
||||
use alkcall::registry::env::LocalOperationEnv;
|
||||
use alkcall::registry::registration::OperationRegistry;
|
||||
use alkcall::registry::spec::{AccessResult, Visibility};
|
||||
use futures::stream::BoxStream;
|
||||
use serde_json::Value;
|
||||
|
||||
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
const SERVICES_SCHEMA: &str = "services/schema";
|
||||
|
||||
/// The shared dispatch spine over the registry, invoking operations for
|
||||
/// the neutral `ResponseEnvelope` result shape both gateway projections
|
||||
/// map to their wire formats. Identity arrives per-call as
|
||||
/// `Option<Identity>` — bearer resolution happens upstream in the auth
|
||||
/// middleware, not here.
|
||||
pub struct GatewayDispatch {
|
||||
registry: Arc<OperationRegistry>,
|
||||
invoke_count: AtomicUsize,
|
||||
}
|
||||
|
||||
impl GatewayDispatch {
|
||||
/// Assemble a dispatch spine over a registry.
|
||||
pub fn new(registry: Arc<OperationRegistry>) -> Self {
|
||||
Self {
|
||||
registry,
|
||||
invoke_count: AtomicUsize::new(0),
|
||||
}
|
||||
}
|
||||
|
||||
/// The registry operations resolve against.
|
||||
pub fn registry(&self) -> &Arc<OperationRegistry> {
|
||||
&self.registry
|
||||
}
|
||||
|
||||
/// How many [`GatewayDispatch::invoke`] calls this spine has
|
||||
/// served. A test-spy accessor: the over-cap batch tests assert it
|
||||
/// stays at zero to prove no dispatch happened before the cap
|
||||
/// rejection (review-002 PRJ-22).
|
||||
pub fn invoke_count(&self) -> usize {
|
||||
self.invoke_count.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Invoke a Query/Mutation op under the 30 s gateway deadline; a
|
||||
/// hung handler surfaces as a `TIMEOUT` error envelope (504).
|
||||
pub async fn invoke(
|
||||
&self,
|
||||
identity: Option<Identity>,
|
||||
op: &str,
|
||||
input: Value,
|
||||
) -> ResponseEnvelope {
|
||||
self.invoke_count.fetch_add(1, Ordering::Relaxed);
|
||||
let operation_name = strip_leading_slash(op).to_string();
|
||||
if let Some(error) =
|
||||
schema_via_call_denial(&self.registry, &operation_name, &input, identity.as_ref())
|
||||
{
|
||||
return ResponseEnvelope::error(uuid::Uuid::new_v4().to_string(), error);
|
||||
}
|
||||
let request_id = uuid::Uuid::new_v4().to_string();
|
||||
let context = self.build_root_context(&request_id, &operation_name, identity);
|
||||
let result = tokio::time::timeout(
|
||||
DEFAULT_TIMEOUT,
|
||||
self.registry.invoke(&operation_name, input, context),
|
||||
)
|
||||
.await;
|
||||
match result {
|
||||
Ok(envelope) => envelope,
|
||||
Err(_elapsed) => ResponseEnvelope::error(
|
||||
request_id,
|
||||
CallError::timeout(format!(
|
||||
"operation did not complete within the {DEFAULT_TIMEOUT:?} gateway deadline"
|
||||
)),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Dispatch a Sub op: the returned stream of envelopes is unbounded
|
||||
/// by the deadline (subscriptions are long-lived per alkcall
|
||||
/// ADR-021); pre-handler failures surface as one error envelope.
|
||||
pub fn invoke_streaming(
|
||||
&self,
|
||||
identity: Option<Identity>,
|
||||
op: &str,
|
||||
input: Value,
|
||||
) -> BoxStream<'static, ResponseEnvelope> {
|
||||
let operation_name = strip_leading_slash(op).to_string();
|
||||
if let Some(error) =
|
||||
schema_via_call_denial(&self.registry, &operation_name, &input, identity.as_ref())
|
||||
{
|
||||
let request_id = uuid::Uuid::new_v4().to_string();
|
||||
return Box::pin(futures::stream::once(async move {
|
||||
ResponseEnvelope::error(request_id, error)
|
||||
}));
|
||||
}
|
||||
let request_id = uuid::Uuid::new_v4().to_string();
|
||||
let context = self.build_root_context_streaming(&request_id, &operation_name, identity);
|
||||
self.registry
|
||||
.invoke_streaming(&operation_name, input, context)
|
||||
}
|
||||
|
||||
/// Dispatch a `Pub` operation (ADR-046, ADR-068): the
|
||||
/// `publish_stream` is the initiator's chunk stream (one
|
||||
/// `Ok(Value)` per published chunk); the handler's final
|
||||
/// `ResponseEnvelope` is the result. Pre-handler failures
|
||||
/// (not-found, forbidden, non-Pub op) surface as a single error
|
||||
/// envelope — the same envelope the `/publish` route maps to HTTP.
|
||||
/// The whole dispatch — chunk streaming and the handler's final
|
||||
/// await alike — is bounded by the same 30 s gateway deadline as
|
||||
/// the Once-op invoke (GW-17): a hung sink handler surfaces as a
|
||||
/// `TIMEOUT` error envelope (504 under the gateway's error
|
||||
/// mapping), not an indefinitely-held HTTP request.
|
||||
pub async fn invoke_sink(
|
||||
&self,
|
||||
identity: Option<Identity>,
|
||||
op: &str,
|
||||
input: Value,
|
||||
publish_stream: alkcall::registry::registration::PublishStream,
|
||||
) -> ResponseEnvelope {
|
||||
let operation_name = strip_leading_slash(op).to_string();
|
||||
let request_id = uuid::Uuid::new_v4().to_string();
|
||||
let context = self.build_root_context_sink(&request_id, &operation_name, identity);
|
||||
let result = tokio::time::timeout(
|
||||
DEFAULT_TIMEOUT,
|
||||
self.registry
|
||||
.invoke_sink(&operation_name, input, publish_stream, context),
|
||||
)
|
||||
.await;
|
||||
match result {
|
||||
Ok(envelope) => envelope,
|
||||
Err(_elapsed) => ResponseEnvelope::error(
|
||||
request_id,
|
||||
CallError::timeout(format!(
|
||||
"operation did not complete within the {DEFAULT_TIMEOUT:?} gateway deadline"
|
||||
)),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_root_context_sink(
|
||||
&self,
|
||||
request_id: &str,
|
||||
operation_name: &str,
|
||||
identity: Option<Identity>,
|
||||
) -> OperationContext {
|
||||
self.build_root_context_inner(request_id, operation_name, identity, false)
|
||||
}
|
||||
|
||||
fn build_root_context(
|
||||
&self,
|
||||
request_id: &str,
|
||||
operation_name: &str,
|
||||
identity: Option<Identity>,
|
||||
) -> OperationContext {
|
||||
self.build_root_context_inner(request_id, operation_name, identity, true)
|
||||
}
|
||||
|
||||
fn build_root_context_streaming(
|
||||
&self,
|
||||
request_id: &str,
|
||||
operation_name: &str,
|
||||
identity: Option<Identity>,
|
||||
) -> OperationContext {
|
||||
self.build_root_context_inner(request_id, operation_name, identity, false)
|
||||
}
|
||||
|
||||
fn build_root_context_inner(
|
||||
&self,
|
||||
request_id: &str,
|
||||
operation_name: &str,
|
||||
identity: Option<Identity>,
|
||||
bounded: bool,
|
||||
) -> OperationContext {
|
||||
let registration = self.registry.registration(operation_name);
|
||||
let (composition_authority, capabilities, scoped_env) = match registration {
|
||||
Some(r) => (
|
||||
r.composition_authority.clone(),
|
||||
r.capabilities.clone(),
|
||||
r.scoped_env.clone().unwrap_or_else(ScopedPeerEnv::empty),
|
||||
),
|
||||
None => (None, Capabilities::new(), ScopedPeerEnv::empty()),
|
||||
};
|
||||
|
||||
let env: Arc<dyn alkcall::registry::env::OperationEnv + Send + Sync> =
|
||||
Arc::new(LocalOperationEnv::new(Arc::clone(&self.registry)));
|
||||
|
||||
OperationContext {
|
||||
request_id: request_id.to_string(),
|
||||
parent_request_id: None,
|
||||
identity,
|
||||
handler_identity: composition_authority,
|
||||
forwarded_for: None,
|
||||
capabilities,
|
||||
metadata: HashMap::new(),
|
||||
deadline: bounded.then(|| Instant::now() + DEFAULT_TIMEOUT),
|
||||
scoped_env,
|
||||
env,
|
||||
abort_policy: AbortPolicy::default(),
|
||||
internal: false,
|
||||
ownership: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn strip_leading_slash(operation_id: &str) -> &str {
|
||||
operation_id.strip_prefix('/').unwrap_or(operation_id)
|
||||
}
|
||||
|
||||
/// The `services/schema` op-path guard (review-002 PRJ-16): when the
|
||||
/// dispatched operation is the schema meta-op, apply the GET `/schema`
|
||||
/// route's visibility + AccessControl checks to the **inner** `name`
|
||||
/// input — 404 for Internal, FORBIDDEN for ACL denial — so no transport
|
||||
/// through this spine can fetch a spec the GET `/schema` route would
|
||||
/// deny for the same identity. The complete fix is the alkcall-side
|
||||
/// handler check (CF-004); this guard stays as defense-in-depth after
|
||||
/// that lands.
|
||||
fn schema_via_call_denial(
|
||||
registry: &OperationRegistry,
|
||||
operation: &str,
|
||||
input: &Value,
|
||||
identity: Option<&Identity>,
|
||||
) -> Option<CallError> {
|
||||
let name = input.get("name").and_then(Value::as_str)?;
|
||||
if !matches!(
|
||||
registry.registration(operation),
|
||||
Some(registration) if registration.spec.name == SERVICES_SCHEMA
|
||||
) {
|
||||
return None;
|
||||
}
|
||||
schema_disclosure_denial(registry, name, identity)
|
||||
}
|
||||
|
||||
/// The per-transport schema-disclosure check shared by the HTTP GET
|
||||
/// `/schema` route, the dispatch-spine `services/schema` guard, and the
|
||||
/// MCP `schema` tool: Internal ops are invisible (404 / NOT_FOUND
|
||||
/// regardless of caller), ACL-forbidden ops are denied (403 /
|
||||
/// FORBIDDEN). One implementation so the transports cannot drift.
|
||||
pub(crate) fn schema_disclosure_denial(
|
||||
registry: &OperationRegistry,
|
||||
operation: &str,
|
||||
identity: Option<&Identity>,
|
||||
) -> Option<CallError> {
|
||||
let name = strip_leading_slash(operation);
|
||||
let registration = registry.registration(name)?;
|
||||
if registration.spec.visibility == Visibility::Internal {
|
||||
return Some(CallError::not_found(operation));
|
||||
}
|
||||
if let AccessResult::Forbidden(message) =
|
||||
registration.spec.access_control.check(identity, None, None)
|
||||
{
|
||||
return Some(CallError::forbidden(message));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use alkcall::registry::registration::{
|
||||
make_handler, make_sink_handler, make_streaming_handler, HandlerKind, HandlerRegistration,
|
||||
OperationProvenance,
|
||||
};
|
||||
use alkcall::registry::spec::{AccessControl, OperationSpec, OperationType, Visibility};
|
||||
use futures::StreamExt;
|
||||
|
||||
// The tests below run against the module's real 30 s deadline; only
|
||||
// the elapsed-time bound (< 60 s, anti-flake) is asserted by hand.
|
||||
|
||||
fn spec(name: &str, visibility: Visibility, op_type: OperationType) -> OperationSpec {
|
||||
OperationSpec::new(
|
||||
name,
|
||||
op_type,
|
||||
visibility,
|
||||
serde_json::json!({}),
|
||||
serde_json::json!({}),
|
||||
vec![],
|
||||
AccessControl::default(),
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
fn echo_registry() -> Arc<OperationRegistry> {
|
||||
let mut registry = OperationRegistry::new();
|
||||
registry
|
||||
.register(HandlerRegistration::new(
|
||||
spec("echo/run", Visibility::External, OperationType::Query),
|
||||
HandlerKind::Once(make_handler(|input, ctx| async move {
|
||||
ResponseEnvelope::ok(ctx.request_id, input)
|
||||
})),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
))
|
||||
.unwrap();
|
||||
Arc::new(registry)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invoke_external_op_round_trips() {
|
||||
let dispatch = GatewayDispatch::new(echo_registry());
|
||||
let envelope = dispatch
|
||||
.invoke(None, "/echo/run", serde_json::json!({ "x": 1 }))
|
||||
.await;
|
||||
assert!(envelope.result.is_ok(), "expected ok, got {envelope:?}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invoke_unknown_op_returns_not_found() {
|
||||
let dispatch = GatewayDispatch::new(echo_registry());
|
||||
let envelope = dispatch
|
||||
.invoke(None, "/missing/op", serde_json::json!({}))
|
||||
.await;
|
||||
match envelope.result {
|
||||
Err(error) => assert_eq!(error.code, "NOT_FOUND"),
|
||||
Ok(v) => panic!("expected error, got {v:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invoke_internal_op_returns_not_found() {
|
||||
let mut registry = OperationRegistry::new();
|
||||
registry
|
||||
.register(HandlerRegistration::new(
|
||||
spec("internal/op", Visibility::Internal, OperationType::Query),
|
||||
HandlerKind::Once(make_handler(|_input, ctx| async move {
|
||||
ResponseEnvelope::ok(ctx.request_id, serde_json::json!({}))
|
||||
})),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
))
|
||||
.unwrap();
|
||||
let dispatch = GatewayDispatch::new(Arc::new(registry));
|
||||
let envelope = dispatch
|
||||
.invoke(None, "/internal/op", serde_json::json!({}))
|
||||
.await;
|
||||
match envelope.result {
|
||||
Err(error) => assert_eq!(error.code, "NOT_FOUND"),
|
||||
Ok(v) => panic!("expected error, got {v:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invoke_enforces_the_default_deadline_on_a_hung_handler() {
|
||||
use std::time::Duration;
|
||||
|
||||
let mut registry = OperationRegistry::new();
|
||||
registry
|
||||
.register(HandlerRegistration::new(
|
||||
spec("hung/op", Visibility::External, OperationType::Query),
|
||||
HandlerKind::Once(make_handler(|_input, ctx| async move {
|
||||
tokio::time::sleep(Duration::from_secs(120)).await;
|
||||
ResponseEnvelope::ok(ctx.request_id, serde_json::json!({}))
|
||||
})),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
))
|
||||
.unwrap();
|
||||
let dispatch = GatewayDispatch::new(Arc::new(registry));
|
||||
let started = std::time::Instant::now();
|
||||
let envelope = dispatch
|
||||
.invoke(None, "/hung/op", serde_json::json!({}))
|
||||
.await;
|
||||
let elapsed = started.elapsed();
|
||||
assert!(
|
||||
elapsed < Duration::from_secs(60),
|
||||
"the 30 s deadline must fire well before the 120 s handler sleep, took {elapsed:?}"
|
||||
);
|
||||
match envelope.result {
|
||||
Err(error) => {
|
||||
assert_eq!(error.code, "TIMEOUT");
|
||||
assert!(error.retryable, "the deadline error is retryable");
|
||||
}
|
||||
Ok(v) => panic!("expected a TIMEOUT error, got {v:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invoke_completes_within_the_deadline_for_a_fast_handler() {
|
||||
let dispatch = GatewayDispatch::new(echo_registry());
|
||||
let envelope = dispatch
|
||||
.invoke(None, "/echo/run", serde_json::json!({}))
|
||||
.await;
|
||||
assert!(envelope.result.is_ok(), "a fast handler must not time out");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invoke_sink_enforces_the_default_deadline_on_a_hung_sink_handler() {
|
||||
use std::time::Duration;
|
||||
|
||||
let mut registry = OperationRegistry::new();
|
||||
registry
|
||||
.register(HandlerRegistration::new(
|
||||
spec("hung/sink", Visibility::External, OperationType::Pub),
|
||||
HandlerKind::Sink(make_sink_handler(|_input, ctx, _chunks| async move {
|
||||
tokio::time::sleep(Duration::from_secs(120)).await;
|
||||
ResponseEnvelope::ok(ctx.request_id, serde_json::json!({}))
|
||||
})),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
))
|
||||
.unwrap();
|
||||
let dispatch = GatewayDispatch::new(Arc::new(registry));
|
||||
let chunks: alkcall::registry::registration::PublishStream =
|
||||
Box::pin(futures::stream::empty());
|
||||
let started = std::time::Instant::now();
|
||||
let envelope = dispatch
|
||||
.invoke_sink(None, "/hung/sink", serde_json::json!({}), chunks)
|
||||
.await;
|
||||
let elapsed = started.elapsed();
|
||||
assert!(
|
||||
elapsed < Duration::from_secs(60),
|
||||
"the 30 s deadline must fire well before the 120 s handler sleep, took {elapsed:?}"
|
||||
);
|
||||
match envelope.result {
|
||||
Err(error) => {
|
||||
assert_eq!(error.code, "TIMEOUT");
|
||||
assert!(error.retryable, "the deadline error is retryable");
|
||||
}
|
||||
Ok(v) => panic!("expected a TIMEOUT error, got {v:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invoke_sink_completes_within_the_deadline_for_a_fast_sink_handler() {
|
||||
let mut registry = OperationRegistry::new();
|
||||
registry
|
||||
.register(HandlerRegistration::new(
|
||||
spec("fast/sink", Visibility::External, OperationType::Pub),
|
||||
HandlerKind::Sink(make_sink_handler(|_input, ctx, mut chunks| async move {
|
||||
use futures::StreamExt;
|
||||
let mut count = 0u64;
|
||||
while let Some(chunk) = chunks.next().await {
|
||||
if chunk.is_ok() {
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
ResponseEnvelope::ok(ctx.request_id, serde_json::json!({ "count": count }))
|
||||
})),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
))
|
||||
.unwrap();
|
||||
let dispatch = GatewayDispatch::new(Arc::new(registry));
|
||||
let chunks: alkcall::registry::registration::PublishStream =
|
||||
Box::pin(futures::stream::iter(vec![
|
||||
Ok(serde_json::json!({ "n": 1 })),
|
||||
Ok(serde_json::json!({ "n": 2 })),
|
||||
]));
|
||||
let envelope = dispatch
|
||||
.invoke_sink(None, "/fast/sink", serde_json::json!({}), chunks)
|
||||
.await;
|
||||
assert!(
|
||||
envelope.result.is_ok(),
|
||||
"a fast sink handler must not time out, got {envelope:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn streaming_sub_op_streams_envelopes() {
|
||||
let mut registry = OperationRegistry::new();
|
||||
registry
|
||||
.register(HandlerRegistration::new(
|
||||
spec("tick/stream", Visibility::External, OperationType::Sub),
|
||||
HandlerKind::Stream(make_streaming_handler(|input, ctx| {
|
||||
let count = input.get("count").and_then(|v| v.as_u64()).unwrap_or(2);
|
||||
let request_id = ctx.request_id.clone();
|
||||
futures::stream::iter(0..count).map(move |i| {
|
||||
ResponseEnvelope::ok(request_id.clone(), serde_json::json!({ "tick": i }))
|
||||
})
|
||||
})),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
))
|
||||
.unwrap();
|
||||
let dispatch = GatewayDispatch::new(Arc::new(registry));
|
||||
let mut stream =
|
||||
dispatch.invoke_streaming(None, "/tick/stream", serde_json::json!({ "count": 3 }));
|
||||
let mut ticks = Vec::new();
|
||||
while let Some(envelope) = stream.next().await {
|
||||
ticks.push(envelope);
|
||||
}
|
||||
assert_eq!(ticks.len(), 3);
|
||||
}
|
||||
|
||||
fn registry_with_services_schema_over(inner_ops: Vec<OperationSpec>) -> Arc<OperationRegistry> {
|
||||
use alkcall::registry::discovery::{services_schema_handler, services_schema_spec};
|
||||
|
||||
let inner = Arc::new({
|
||||
let mut registry = OperationRegistry::new();
|
||||
for op_spec in &inner_ops {
|
||||
registry
|
||||
.register(HandlerRegistration::new(
|
||||
op_spec.clone(),
|
||||
HandlerKind::Once(make_handler(|input, ctx| async move {
|
||||
ResponseEnvelope::ok(ctx.request_id, input)
|
||||
})),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
))
|
||||
.unwrap();
|
||||
}
|
||||
registry
|
||||
});
|
||||
let mut registry = OperationRegistry::new();
|
||||
for op_spec in &inner_ops {
|
||||
registry
|
||||
.register(HandlerRegistration::new(
|
||||
op_spec.clone(),
|
||||
HandlerKind::Once(make_handler(|input, ctx| async move {
|
||||
ResponseEnvelope::ok(ctx.request_id, input)
|
||||
})),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
))
|
||||
.unwrap();
|
||||
}
|
||||
registry
|
||||
.register(HandlerRegistration::new(
|
||||
services_schema_spec(),
|
||||
HandlerKind::Once(services_schema_handler(Arc::clone(&inner))),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
))
|
||||
.unwrap();
|
||||
Arc::new(registry)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invoke_of_services_schema_with_internal_inner_name_is_blocked_pre_dispatch() {
|
||||
let registry = registry_with_services_schema_over(vec![spec(
|
||||
"secret/op",
|
||||
Visibility::Internal,
|
||||
OperationType::Query,
|
||||
)]);
|
||||
let dispatch = GatewayDispatch::new(registry);
|
||||
let envelope = dispatch
|
||||
.invoke(
|
||||
None,
|
||||
"services/schema",
|
||||
serde_json::json!({ "name": "secret/op" }),
|
||||
)
|
||||
.await;
|
||||
match envelope.result {
|
||||
Err(error) => {
|
||||
assert_eq!(error.code, "NOT_FOUND");
|
||||
assert!(error.message.contains("secret/op"));
|
||||
}
|
||||
Ok(v) => panic!("the internal op spec must not be returned, got {v:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invoke_of_services_schema_with_authorized_inner_name_still_projects() {
|
||||
let registry = registry_with_services_schema_over(vec![spec(
|
||||
"public/op",
|
||||
Visibility::External,
|
||||
OperationType::Query,
|
||||
)]);
|
||||
let dispatch = GatewayDispatch::new(registry);
|
||||
let envelope = dispatch
|
||||
.invoke(
|
||||
None,
|
||||
"services/schema",
|
||||
serde_json::json!({ "name": "public/op" }),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
envelope.result.is_ok(),
|
||||
"an allowed inner name must still project, got {envelope:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
envelope
|
||||
.result
|
||||
.as_ref()
|
||||
.ok()
|
||||
.and_then(|v| v.get("name"))
|
||||
.and_then(Value::as_str),
|
||||
Some("public/op")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invoke_of_services_schema_with_forbidden_inner_name_is_denied() {
|
||||
let restricted = OperationSpec::new(
|
||||
"admin/op",
|
||||
OperationType::Query,
|
||||
Visibility::External,
|
||||
serde_json::json!({}),
|
||||
serde_json::json!({}),
|
||||
vec![],
|
||||
AccessControl {
|
||||
required_scopes: vec!["admin".to_string()],
|
||||
..Default::default()
|
||||
},
|
||||
None,
|
||||
);
|
||||
let registry = registry_with_services_schema_over(vec![restricted]);
|
||||
let dispatch = GatewayDispatch::new(registry);
|
||||
let envelope = dispatch
|
||||
.invoke(
|
||||
Some(Identity {
|
||||
id: "user".to_string(),
|
||||
scopes: vec!["user".to_string()],
|
||||
resources: HashMap::new(),
|
||||
}),
|
||||
"services/schema",
|
||||
serde_json::json!({ "name": "admin/op" }),
|
||||
)
|
||||
.await;
|
||||
match envelope.result {
|
||||
Err(error) => assert_eq!(error.code, "FORBIDDEN"),
|
||||
Ok(v) => panic!("the ACL-denied spec must not be returned, got {v:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invoke_streaming_of_services_schema_with_internal_inner_name_is_blocked() {
|
||||
let registry = registry_with_services_schema_over(vec![spec(
|
||||
"secret/op",
|
||||
Visibility::Internal,
|
||||
OperationType::Query,
|
||||
)]);
|
||||
let dispatch = GatewayDispatch::new(registry);
|
||||
let mut stream = dispatch.invoke_streaming(
|
||||
None,
|
||||
"services/schema",
|
||||
serde_json::json!({ "name": "secret/op" }),
|
||||
);
|
||||
let envelopes: Vec<ResponseEnvelope> = stream.by_ref().collect().await;
|
||||
assert_eq!(envelopes.len(), 1, "the guard error is the only item");
|
||||
match &envelopes[0].result {
|
||||
Err(error) => assert_eq!(error.code, "NOT_FOUND"),
|
||||
Ok(v) => panic!("the internal op spec must not be returned, got {v:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,7 @@
|
||||
pub mod dispatch;
|
||||
pub mod error;
|
||||
pub mod routes;
|
||||
pub(crate) mod schema_cache;
|
||||
|
||||
#[cfg(feature = "mcp")]
|
||||
pub(crate) use dispatch::schema_disclosure_denial;
|
||||
pub use dispatch::GatewayDispatch;
|
||||
pub use alkcall::gateway::{schema_disclosure_denial, GatewayDispatch, DEFAULT_DEADLINE};
|
||||
pub use routes::CallRequest;
|
||||
|
||||
/// The maximum number of operations accepted in a single batch,
|
||||
|
||||
@@ -43,10 +43,8 @@ use jsonschema::Validator;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use super::dispatch::GatewayDispatch;
|
||||
use super::error::call_error_to_http_response_with_identity;
|
||||
use super::schema_cache::{CompileFailed, PublishSchemaCache};
|
||||
use super::MAX_BATCH_OPERATIONS;
|
||||
use super::{GatewayDispatch, MAX_BATCH_OPERATIONS};
|
||||
use crate::server::auth::ResolvedIdentity;
|
||||
use crate::server::state::RouterState;
|
||||
|
||||
@@ -84,18 +82,11 @@ type ByteStream = futures::stream::BoxStream<'static, Result<Bytes, axum::Error>
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct GatewayState {
|
||||
registry: Arc<OperationRegistry>,
|
||||
publish_schemas: crate::gateway::schema_cache::PublishSchemaCache,
|
||||
}
|
||||
|
||||
impl GatewayState {
|
||||
pub(crate) fn new(
|
||||
registry: Arc<OperationRegistry>,
|
||||
publish_schemas: crate::gateway::schema_cache::PublishSchemaCache,
|
||||
) -> Self {
|
||||
Self {
|
||||
registry,
|
||||
publish_schemas,
|
||||
}
|
||||
pub(crate) fn new(registry: Arc<OperationRegistry>) -> Self {
|
||||
Self { registry }
|
||||
}
|
||||
|
||||
fn dispatch(&self) -> GatewayDispatch {
|
||||
@@ -105,7 +96,7 @@ impl GatewayState {
|
||||
|
||||
impl FromRef<RouterState> for GatewayState {
|
||||
fn from_ref(state: &RouterState) -> Self {
|
||||
GatewayState::new(Arc::clone(&state.registry), state.publish_schemas.clone())
|
||||
GatewayState::new(Arc::clone(&state.registry))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -255,13 +246,13 @@ pub type SubscribeStream = BoxStream<'static, Result<Event, Infallible>>;
|
||||
/// write-half close semantics.
|
||||
///
|
||||
/// Chunk validation (GW-01, ADR-046 §4): each chunk is validated
|
||||
/// against the op's `publish_schema` via the compile-once
|
||||
/// [`PublishSchemaCache`]. A schema that **fails to compile** is
|
||||
/// fail-closed, not fail-open (review-001 post-remediation follow-up):
|
||||
/// the chunk stream terminates with `INTERNAL` before any chunk reaches
|
||||
/// the handler — the unvalidated-ingest hole the per-request
|
||||
/// `warn`-and-skip opened is shut. The compile error itself is logged
|
||||
/// at error level and never echoed on the wire.
|
||||
/// against the op's `publish_schema` compiled at registration time
|
||||
/// (`OperationRegistry::publish_validator`, alkcall CF-003). The
|
||||
/// compile happens once, before the op is callable at all — an
|
||||
/// un-compilable schema can never reach a registry, so the HTTP path
|
||||
/// has no compile step and no fail-open window. A chunk that fails
|
||||
/// validation is a terminal `INVALID_INPUT` before any chunk reaches
|
||||
/// the handler.
|
||||
pub(crate) async fn publish_handler(
|
||||
State(state): State<GatewayState>,
|
||||
ResolvedIdentity(identity): ResolvedIdentity,
|
||||
@@ -300,7 +291,6 @@ pub(crate) async fn publish_handler(
|
||||
let chunks = NdjsonChunkStream::new(
|
||||
header_lines,
|
||||
Arc::clone(&state.registry),
|
||||
state.publish_schemas.clone(),
|
||||
operation.clone(),
|
||||
Some(first_chunk),
|
||||
);
|
||||
@@ -313,59 +303,48 @@ pub(crate) async fn publish_handler(
|
||||
|
||||
/// The per-request `publish_schema` situation for a `/publish` call
|
||||
/// (ADR-046 §4). Resolution is lazy — the chunk stream resolves against
|
||||
/// the [`PublishSchemaCache`] on its first poll, which runs only after
|
||||
/// `invoke_sink`'s pre-checks have admitted the operation (an unknown,
|
||||
/// internal, or wrong-type op is rejected before any schema resolution
|
||||
/// or compile happens, preserving the GW-11 dispatch-owned pre-check
|
||||
/// order and keeping 404/403/422 intact).
|
||||
/// the registry's [`alkcall::registry::registration::OperationRegistry::publish_validator`]
|
||||
/// cache on its first poll, which runs only after `invoke_sink`'s
|
||||
/// pre-checks have admitted the operation (an unknown, internal, or
|
||||
/// wrong-type op is rejected before any schema resolution happens,
|
||||
/// preserving the GW-11 dispatch-owned pre-check order and keeping
|
||||
/// 404/403/422 intact). The validator is compiled at **registration
|
||||
/// time** (alkcall CF-003): an un-compilable schema can never reach a
|
||||
/// registry, so there is no compile here and no fail-open window —
|
||||
/// every `/publish` chunk validates or the op has no schema.
|
||||
enum PublishSchemaState {
|
||||
/// Not yet resolved: the stream holds the registry + cache until
|
||||
/// the first chunk is polled.
|
||||
/// Not yet resolved: the stream holds the registry until the first
|
||||
/// chunk is polled.
|
||||
Unresolved {
|
||||
registry: Arc<OperationRegistry>,
|
||||
cache: PublishSchemaCache,
|
||||
operation: String,
|
||||
},
|
||||
/// The op registers no `publish_schema`; chunks pass as-is (the
|
||||
/// wire path's behavior for schema-less Pub ops).
|
||||
Unvalidated,
|
||||
/// Chunks are validated against this compiled validator.
|
||||
Validated(std::sync::Arc<jsonschema::Validator>),
|
||||
/// The op's `publish_schema` failed to compile (GW-01 follow-up):
|
||||
/// fail-closed — the stream yields one terminal `INTERNAL` error
|
||||
/// and ends, so no chunk ever reaches the handler unvalidated.
|
||||
Failed(CompileFailed),
|
||||
Validated(jsonschema::Validator),
|
||||
}
|
||||
|
||||
impl PublishSchemaState {
|
||||
fn resolve(&mut self) {
|
||||
let Self::Unresolved {
|
||||
registry,
|
||||
cache,
|
||||
operation,
|
||||
} = self
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let operation = std::mem::take(operation);
|
||||
*self = match cache.validator(registry, &operation) {
|
||||
Ok(None) => Self::Unvalidated,
|
||||
Ok(Some(validator)) => Self::Validated(validator),
|
||||
Err(compile_failed) => {
|
||||
tracing::debug!(
|
||||
operation = %operation,
|
||||
"rejecting /publish: the operation's publish_schema could not be compiled \
|
||||
(fail-closed)"
|
||||
);
|
||||
Self::Failed(compile_failed)
|
||||
}
|
||||
*self = match registry.publish_validator(&operation) {
|
||||
Some(validator) => Self::Validated(validator),
|
||||
None => Self::Unvalidated,
|
||||
};
|
||||
}
|
||||
|
||||
/// Validate one chunk; also flips a terminal compile failure into
|
||||
/// stream-end after the first error item (an `Err` from this stream
|
||||
/// is terminal — the handler sees the error and the stream closes,
|
||||
/// mirroring the wire pump's `send(Err)` + `break`).
|
||||
/// Validate one chunk. An `Err` from this stream is terminal — the
|
||||
/// handler sees the error and the stream closes, mirroring the wire
|
||||
/// pump's `send(Err)` + `break`.
|
||||
fn validate_chunk(
|
||||
&mut self,
|
||||
value: Value,
|
||||
@@ -383,11 +362,6 @@ impl PublishSchemaState {
|
||||
.with_details(json!({ "chunk": value })))))
|
||||
}
|
||||
}
|
||||
Self::Failed(failed) => {
|
||||
let error = failed.call_error();
|
||||
*self = Self::Unvalidated;
|
||||
std::task::Poll::Ready(Some(Err(error)))
|
||||
}
|
||||
Self::Unresolved { .. } => unreachable!("resolve() runs before validation"),
|
||||
}
|
||||
}
|
||||
@@ -582,9 +556,9 @@ impl LineError {
|
||||
/// JSON, a schema violation, or a body-read failure yields a terminal
|
||||
/// `Err` item — the same shape an initiator-side `call.error` produces
|
||||
/// on the wire, and equally terminal: like the wire pump's
|
||||
/// `send(Err)` + `break`, the first `Err` ends the stream. A schema
|
||||
/// that failed to compile yields that terminal `Err(INTERNAL)` on the
|
||||
/// first chunk — fail-closed (review-001 post-remediation follow-up).
|
||||
/// `send(Err)` + `break`, the first `Err` ends the stream. The
|
||||
/// validator itself is compiled at registration time (alkcall CF-003),
|
||||
/// so there is no compile on this path at all.
|
||||
struct NdjsonChunkStream {
|
||||
lines: std::pin::Pin<Box<BufferedLines>>,
|
||||
schema_state: PublishSchemaState,
|
||||
@@ -596,7 +570,6 @@ impl NdjsonChunkStream {
|
||||
fn new(
|
||||
lines: BufferedLines,
|
||||
registry: Arc<OperationRegistry>,
|
||||
cache: PublishSchemaCache,
|
||||
operation: String,
|
||||
pending_first: Option<Value>,
|
||||
) -> Self {
|
||||
@@ -604,7 +577,6 @@ impl NdjsonChunkStream {
|
||||
lines: Box::pin(lines),
|
||||
schema_state: PublishSchemaState::Unresolved {
|
||||
registry,
|
||||
cache,
|
||||
operation,
|
||||
},
|
||||
pending_first,
|
||||
@@ -1165,7 +1137,6 @@ mod tests {
|
||||
crate::websocket::DEFAULT_WS_MAX_SESSIONS,
|
||||
)),
|
||||
ws_idle_timeout: Some(crate::websocket::DEFAULT_WS_IDLE_TIMEOUT),
|
||||
publish_schemas: crate::gateway::schema_cache::PublishSchemaCache::new(),
|
||||
};
|
||||
let auth_state = Arc::clone(&provider);
|
||||
gateway_router()
|
||||
@@ -2952,139 +2923,44 @@ mod tests {
|
||||
|
||||
// --- publish_schema fail-closed (GW-01 follow-up) ----------------------
|
||||
|
||||
fn registry_with_broken_publish_schema() -> Arc<OperationRegistry> {
|
||||
/// CF-003 (alkcall ledger): an un-compilable `publish_schema` is a
|
||||
/// registration error — the op never becomes callable, so there is
|
||||
/// no `/publish` request to fail and no fail-open window.
|
||||
#[test]
|
||||
fn uncompilable_publish_schema_is_rejected_at_registration() {
|
||||
let mut registry = OperationRegistry::new();
|
||||
registry
|
||||
.register(HandlerRegistration::new(
|
||||
OperationSpec::new(
|
||||
"ingest/broken",
|
||||
OperationType::Pub,
|
||||
Visibility::External,
|
||||
json!({}),
|
||||
json!({}),
|
||||
vec![],
|
||||
AccessControl::default(),
|
||||
None,
|
||||
)
|
||||
.with_publish_schema(json!({ "required": "n" })), // `required` must be an array
|
||||
HandlerKind::Sink(make_sink_handler(
|
||||
|_unused_input, ctx, mut chunks| async move {
|
||||
use futures::StreamExt;
|
||||
let mut collected: Vec<Value> = Vec::new();
|
||||
while let Some(chunk) = chunks.next().await {
|
||||
match chunk {
|
||||
Ok(v) => collected.push(v),
|
||||
Err(e) => return ResponseEnvelope::error(ctx.request_id, e),
|
||||
}
|
||||
}
|
||||
ResponseEnvelope::ok(
|
||||
ctx.request_id,
|
||||
json!({ "count": collected.len(), "chunks": collected }),
|
||||
)
|
||||
},
|
||||
)),
|
||||
OperationProvenance::Local,
|
||||
let result = registry.register(HandlerRegistration::new(
|
||||
OperationSpec::new(
|
||||
"ingest/broken",
|
||||
OperationType::Pub,
|
||||
Visibility::External,
|
||||
json!({}),
|
||||
json!({}),
|
||||
vec![],
|
||||
AccessControl::default(),
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
))
|
||||
.unwrap();
|
||||
Arc::new(registry)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn publish_with_uncompilable_schema_fails_closed_with_internal() {
|
||||
let router = build_router(registry_with_broken_publish_schema(), unused_provider());
|
||||
let body = ndjson(&[
|
||||
json!({ "operation": "ingest/broken", "chunk": { "n": 1 } }),
|
||||
json!({ "n": 2 }),
|
||||
]);
|
||||
let req = raw_request("POST", "/publish", body);
|
||||
let (status, resp) = send(router, req).await;
|
||||
assert_eq!(
|
||||
status,
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"an un-compilable publish_schema must fail the request loudly (500), not skip validation: {resp}"
|
||||
);
|
||||
assert_eq!(
|
||||
resp.get("code"),
|
||||
Some(&json!("INTERNAL")),
|
||||
"fail-closed maps to INTERNAL, not INVALID_INPUT (this is a server-side defect): {resp}"
|
||||
)
|
||||
.with_publish_schema(json!({ "required": "n" })), // `required` must be an array
|
||||
HandlerKind::Sink(make_sink_handler(|_input, ctx, _chunks| async move {
|
||||
ResponseEnvelope::ok(ctx.request_id, Value::Null)
|
||||
})),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
));
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"an un-compilable publish_schema must be rejected at registration"
|
||||
);
|
||||
assert!(
|
||||
resp.get("message")
|
||||
.and_then(|m| m.as_str())
|
||||
.map(|m| m.contains("failed to compile"))
|
||||
.unwrap_or(false),
|
||||
"the wire message names the compile failure without echoing schema internals: {resp}"
|
||||
registry.registration("ingest/broken").is_none(),
|
||||
"the un-compilable op must not be registered"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn publish_with_uncompilable_schema_never_delivers_chunks_to_the_handler() {
|
||||
let mut registry = OperationRegistry::new();
|
||||
registry
|
||||
.register(HandlerRegistration::new(
|
||||
OperationSpec::new(
|
||||
"ingest/spy",
|
||||
OperationType::Pub,
|
||||
Visibility::External,
|
||||
json!({}),
|
||||
json!({}),
|
||||
vec![],
|
||||
AccessControl::default(),
|
||||
None,
|
||||
)
|
||||
.with_publish_schema(json!({ "type": 123 })), // non-string `type` never compiles
|
||||
HandlerKind::Sink(make_sink_handler(|_input, ctx, mut chunks| async move {
|
||||
use futures::StreamExt;
|
||||
let received: Vec<Result<Value, CallError>> = chunks.by_ref().collect().await;
|
||||
ResponseEnvelope::ok(
|
||||
ctx.request_id,
|
||||
json!({
|
||||
"chunk_count": received.len(),
|
||||
"ok_chunks": received.iter().filter(|r| r.is_ok()).count(),
|
||||
"error_codes": received
|
||||
.iter()
|
||||
.filter_map(|r| r.as_ref().err().map(|e| e.code.clone()))
|
||||
.collect::<Vec<String>>(),
|
||||
}),
|
||||
)
|
||||
})),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
))
|
||||
.unwrap();
|
||||
let router = build_router(Arc::new(registry), unused_provider());
|
||||
let body = ndjson(&[
|
||||
json!({ "operation": "ingest/spy", "chunk": { "n": 1 } }),
|
||||
json!({ "n": 2 }),
|
||||
]);
|
||||
let (status, resp) = send(router, raw_request("POST", "/publish", body)).await;
|
||||
let output = resp.get("output").expect("spy handler output");
|
||||
assert_eq!(
|
||||
output.get("ok_chunks"),
|
||||
Some(&json!(0)),
|
||||
"no chunk may reach the handler when the publish_schema failed to compile: {resp}"
|
||||
);
|
||||
assert_eq!(
|
||||
output.get("chunk_count"),
|
||||
Some(&json!(1)),
|
||||
"exactly one item flows: the terminal INTERNAL error (stream ends after it): {resp}"
|
||||
);
|
||||
assert_eq!(
|
||||
output.get("error_codes"),
|
||||
Some(&json!(["INTERNAL"])),
|
||||
"the terminal item is the fail-closed INTERNAL compile error: {resp}"
|
||||
);
|
||||
let _ = status;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn publish_hot_reload_replacement_schema_is_picked_up() {
|
||||
let cache = PublishSchemaCache::new();
|
||||
let mut registry = OperationRegistry::new();
|
||||
registry
|
||||
.register(HandlerRegistration::new(
|
||||
@@ -3114,14 +2990,14 @@ mod tests {
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
let v1 = cache
|
||||
.validator(®istry, "ingest/typed")
|
||||
.unwrap()
|
||||
let v1 = registry
|
||||
.publish_validator("ingest/typed")
|
||||
.expect("compiled validator");
|
||||
assert!(!v1.is_valid(&json!({ "n": 1, "extra": true })));
|
||||
|
||||
// Hot reload: re-register the same op with a schema that drops
|
||||
// `additionalProperties`; the same cache entry must recompile.
|
||||
// `additionalProperties`; the registry's per-op validator cache
|
||||
// must follow the new registration.
|
||||
registry
|
||||
.register(HandlerRegistration::new(
|
||||
OperationSpec::new(
|
||||
@@ -3149,14 +3025,9 @@ mod tests {
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
let v2 = cache
|
||||
.validator(®istry, "ingest/typed")
|
||||
.unwrap()
|
||||
let v2 = registry
|
||||
.publish_validator("ingest/typed")
|
||||
.expect("recompiled validator");
|
||||
assert!(
|
||||
!Arc::ptr_eq(&v1, &v2),
|
||||
"a changed schema value must invalidate the cached validator"
|
||||
);
|
||||
assert!(
|
||||
v2.is_valid(&json!({ "n": 1, "extra": true })),
|
||||
"the recompiled schema must reflect the new registration"
|
||||
|
||||
@@ -1,258 +0,0 @@
|
||||
//! Compile-once cache for operation `publish_schema` validators
|
||||
//! (ADR-046 §4, ADR-068; review-001 post-remediation follow-up).
|
||||
//!
|
||||
//! The `/publish` route validates each chunk against the op's
|
||||
//! `publish_schema`. Compiling the schema per request is both a
|
||||
//! per-request CPU/allocation cost on the hot path and — worse — the
|
||||
//! GW-01 follow-up fail-open: when compilation fails, chunks flowed to
|
||||
//! the handler **unvalidated** behind only a `tracing::warn`, quietly
|
||||
//! reopening the transport-dependent validation gap GW-01 closed.
|
||||
//!
|
||||
//! This cache compiles each op's schema **once** and remembers the
|
||||
//! outcome — [`PublishSchemaCache::validator`] returns either a shared
|
||||
//! compiled validator or a cached compile failure. The failure is not
|
||||
//! retried until the registration changes: an un-compilable schema is a
|
||||
//! deployment defect (registry assembly), not a transient condition.
|
||||
//!
|
||||
//! Invalidation is by schema **value**: the raw `serde_json::Value` is
|
||||
//! stored alongside the compiled validator, and a request whose op's
|
||||
//! current `publish_schema` differs from the cached one recompiles. This
|
||||
//! makes re-registration (hot reload) correct without lifetime coupling
|
||||
//! to registry internals: a new schema value is picked up on the next
|
||||
//! `/publish` request to that op.
|
||||
//!
|
||||
//! The registry is defined not to mutate after assembly
|
||||
//! (`CachedOpenAPIDoc` relies on the same invariant), so in practice
|
||||
//! each schema compiles once per process — the value comparison is
|
||||
//! defense in depth for re-assembly paths.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use alkcall::protocol::wire::CallError;
|
||||
use alkcall::registry::registration::OperationRegistry;
|
||||
use jsonschema::Validator;
|
||||
use parking_lot::RwLock;
|
||||
use serde_json::Value;
|
||||
|
||||
/// Compiled-validator (or recorded compile failure) per operation name.
|
||||
#[derive(Clone, Default)]
|
||||
pub(crate) struct PublishSchemaCache {
|
||||
entries: Arc<RwLock<HashMap<String, CacheEntry>>>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
enum CacheEntry {
|
||||
Compiled {
|
||||
schema: Value,
|
||||
validator: Arc<Validator>,
|
||||
},
|
||||
/// Compile failed; the error was already logged at compile-attempt
|
||||
/// time and every subsequent request fails closed. Held only so a
|
||||
/// schema whose value still equals the failing one is not
|
||||
/// recompiled (and re-logged) on every request.
|
||||
Failed { schema: Value },
|
||||
}
|
||||
|
||||
impl PublishSchemaCache {
|
||||
pub(crate) fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// The compiled validator for `op`'s current `publish_schema`, from
|
||||
/// the cache when the schema value is unchanged, compiled otherwise.
|
||||
///
|
||||
/// `Ok(None)` means the op has no `publish_schema` (chunks flow
|
||||
/// unvalidated, matching the wire path). `Err` is a **failed**
|
||||
/// compile — cached, loud, never a validator: the caller must fail
|
||||
/// the publish rather than skip validation.
|
||||
pub(crate) fn validator(
|
||||
&self,
|
||||
registry: &OperationRegistry,
|
||||
op: &str,
|
||||
) -> Result<Option<Arc<Validator>>, CompileFailed> {
|
||||
let Some(schema) = registry
|
||||
.registration(op)
|
||||
.and_then(|reg| reg.spec.publish_schema.clone())
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
if let Some(cached) = Self::lookup(&self.entries, op, &schema) {
|
||||
return cached;
|
||||
}
|
||||
Self::compile(&self.entries, op, schema)
|
||||
}
|
||||
|
||||
fn lookup(
|
||||
entries: &RwLock<HashMap<String, CacheEntry>>,
|
||||
op: &str,
|
||||
schema: &Value,
|
||||
) -> Option<Result<Option<Arc<Validator>>, CompileFailed>> {
|
||||
let guard = entries.read();
|
||||
match guard.get(op) {
|
||||
Some(CacheEntry::Compiled {
|
||||
schema: cached_schema,
|
||||
validator,
|
||||
}) if cached_schema == schema => Some(Ok(Some(Arc::clone(validator)))),
|
||||
Some(CacheEntry::Failed {
|
||||
schema: cached_schema,
|
||||
}) if cached_schema == schema => Some(Err(CompileFailed {
|
||||
operation: op.to_string(),
|
||||
})),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn compile(
|
||||
entries: &RwLock<HashMap<String, CacheEntry>>,
|
||||
op: &str,
|
||||
schema: Value,
|
||||
) -> Result<Option<Arc<Validator>>, CompileFailed> {
|
||||
match jsonschema::options().build(&schema) {
|
||||
Ok(validator) => {
|
||||
let validator = Arc::new(validator);
|
||||
entries.write().insert(
|
||||
op.to_string(),
|
||||
CacheEntry::Compiled {
|
||||
schema,
|
||||
validator: Arc::clone(&validator),
|
||||
},
|
||||
);
|
||||
Ok(Some(validator))
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::error!(
|
||||
operation = %op,
|
||||
error = %error,
|
||||
"publish_schema failed to compile; every /publish request to this operation \
|
||||
fails (fail-closed) until the schema is corrected and re-registered"
|
||||
);
|
||||
entries
|
||||
.write()
|
||||
.insert(op.to_string(), CacheEntry::Failed { schema });
|
||||
Err(CompileFailed {
|
||||
operation: op.to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A `publish_schema` that could not be compiled (review-001
|
||||
/// post-remediation, GW-01 follow-up): the publish request fails closed
|
||||
/// with `INTERNAL`; the compile error itself stays in the server log —
|
||||
/// an untrusted schema's error text must not be echoed on the wire
|
||||
/// (same discipline as the `/openapi.json` cache-miss 500).
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct CompileFailed {
|
||||
operation: String,
|
||||
}
|
||||
|
||||
impl CompileFailed {
|
||||
pub(crate) fn call_error(&self) -> CallError {
|
||||
CallError::internal(format!(
|
||||
"publish_schema for operation '{}' failed to compile; publishing is disabled for \
|
||||
this operation",
|
||||
self.operation
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use alkcall::core::types::Capabilities;
|
||||
use alkcall::registry::registration::{
|
||||
make_sink_handler, HandlerKind, HandlerRegistration, OperationProvenance,
|
||||
};
|
||||
use alkcall::registry::spec::{AccessControl, OperationSpec, OperationType, Visibility};
|
||||
use serde_json::json;
|
||||
|
||||
fn registry_with_pub_op(name: &str, schema: Option<Value>) -> OperationRegistry {
|
||||
let mut registry = OperationRegistry::new();
|
||||
let mut spec = OperationSpec::new(
|
||||
name,
|
||||
OperationType::Pub,
|
||||
Visibility::External,
|
||||
json!({}),
|
||||
json!({}),
|
||||
vec![],
|
||||
AccessControl::default(),
|
||||
None,
|
||||
);
|
||||
if let Some(schema) = schema {
|
||||
spec = spec.with_publish_schema(schema);
|
||||
}
|
||||
registry
|
||||
.register(HandlerRegistration::new(
|
||||
spec,
|
||||
HandlerKind::Sink(make_sink_handler(|_input, ctx, _chunks| async move {
|
||||
alkcall::protocol::wire::ResponseEnvelope::ok(ctx.request_id, Value::Null)
|
||||
})),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
))
|
||||
.unwrap();
|
||||
registry
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_publish_schema_yields_ok_none() {
|
||||
let cache = PublishSchemaCache::new();
|
||||
let registry = registry_with_pub_op("ingest/push", None);
|
||||
assert!(matches!(
|
||||
cache.validator(®istry, "ingest/push"),
|
||||
Ok(None)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_op_yields_ok_none() {
|
||||
let cache = PublishSchemaCache::new();
|
||||
let registry = OperationRegistry::new();
|
||||
assert!(matches!(cache.validator(®istry, "no/such"), Ok(None)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_schema_compiles_and_validates() {
|
||||
let cache = PublishSchemaCache::new();
|
||||
let registry = registry_with_pub_op(
|
||||
"ingest/typed",
|
||||
Some(json!({ "type": "object", "required": ["n"] })),
|
||||
);
|
||||
let validator = cache.validator(®istry, "ingest/typed").unwrap().unwrap();
|
||||
assert!(validator.is_valid(&json!({ "n": 1 })));
|
||||
assert!(!validator.is_valid(&json!({})));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_lookups_return_the_same_cached_validator() {
|
||||
let cache = PublishSchemaCache::new();
|
||||
let registry = registry_with_pub_op(
|
||||
"ingest/typed",
|
||||
Some(json!({ "type": "object", "required": ["n"] })),
|
||||
);
|
||||
let first = cache.validator(®istry, "ingest/typed").unwrap().unwrap();
|
||||
let second = cache.validator(®istry, "ingest/typed").unwrap().unwrap();
|
||||
assert!(
|
||||
Arc::ptr_eq(&first, &second),
|
||||
"cache must serve the same compiled validator"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uncompilable_schema_fails_closed_and_stays_failed() {
|
||||
let cache = PublishSchemaCache::new();
|
||||
let registry = registry_with_pub_op(
|
||||
"ingest/broken",
|
||||
Some(json!({ "required": "n" })), // `required` must be an array
|
||||
);
|
||||
assert!(cache.validator(®istry, "ingest/broken").is_err());
|
||||
assert!(
|
||||
cache.validator(®istry, "ingest/broken").is_err(),
|
||||
"the cached failure must persist — no retry, no validator"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -136,7 +136,6 @@ impl HttpAdapter {
|
||||
ws_sessions: Arc::clone(&ws_sessions),
|
||||
ws_session_slots: Arc::clone(&ws_session_slots),
|
||||
ws_idle_timeout,
|
||||
publish_schemas: crate::gateway::schema_cache::PublishSchemaCache::new(),
|
||||
};
|
||||
let router = build_router(state, None);
|
||||
Self {
|
||||
@@ -166,7 +165,6 @@ impl HttpAdapter {
|
||||
ws_sessions: Arc::clone(&self.ws_sessions),
|
||||
ws_session_slots: Arc::clone(&self.ws_session_slots),
|
||||
ws_idle_timeout: self.ws_idle_timeout,
|
||||
publish_schemas: crate::gateway::schema_cache::PublishSchemaCache::new(),
|
||||
};
|
||||
// `extra_routes` is borrowed, not consumed (SRV-05): a builder
|
||||
// call after `with_extra_routes` must keep the custom routes in
|
||||
@@ -188,7 +186,6 @@ impl HttpAdapter {
|
||||
ws_sessions: Arc::clone(&self.ws_sessions),
|
||||
ws_session_slots: Arc::clone(&self.ws_session_slots),
|
||||
ws_idle_timeout: self.ws_idle_timeout,
|
||||
publish_schemas: crate::gateway::schema_cache::PublishSchemaCache::new(),
|
||||
};
|
||||
self.router = build_router(state, Some(routes.clone()));
|
||||
self.extra_routes = Some(routes);
|
||||
@@ -212,7 +209,6 @@ impl HttpAdapter {
|
||||
ws_sessions: Arc::clone(&self.ws_sessions),
|
||||
ws_session_slots: Self::rebuild_session_slots(max_sessions),
|
||||
ws_idle_timeout: self.ws_idle_timeout,
|
||||
publish_schemas: crate::gateway::schema_cache::PublishSchemaCache::new(),
|
||||
};
|
||||
self.router = build_router(state, self.extra_routes.clone());
|
||||
self
|
||||
@@ -244,7 +240,6 @@ impl HttpAdapter {
|
||||
ws_sessions: Arc::clone(&self.ws_sessions),
|
||||
ws_session_slots: Arc::clone(&self.ws_session_slots),
|
||||
ws_idle_timeout: self.ws_idle_timeout,
|
||||
publish_schemas: crate::gateway::schema_cache::PublishSchemaCache::new(),
|
||||
};
|
||||
self.router = build_router(state, self.extra_routes.clone());
|
||||
self
|
||||
@@ -1156,7 +1151,6 @@ mod tests {
|
||||
crate::websocket::DEFAULT_WS_MAX_SESSIONS,
|
||||
)),
|
||||
ws_idle_timeout: Some(crate::websocket::DEFAULT_WS_IDLE_TIMEOUT),
|
||||
publish_schemas: crate::gateway::schema_cache::PublishSchemaCache::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -48,10 +48,6 @@ pub(crate) struct RouterState {
|
||||
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>,
|
||||
/// Compile-once `publish_schema` validator cache for the `/publish`
|
||||
/// route (GW-01 follow-up); built empty at adapter construction and
|
||||
/// populated lazily per operation.
|
||||
pub(crate) publish_schemas: crate::gateway::schema_cache::PublishSchemaCache,
|
||||
}
|
||||
|
||||
impl axum::extract::FromRef<RouterState> for crate::websocket::SessionState {
|
||||
@@ -100,7 +96,6 @@ mod tests {
|
||||
crate::websocket::DEFAULT_WS_MAX_SESSIONS,
|
||||
)),
|
||||
ws_idle_timeout: Some(crate::websocket::DEFAULT_WS_IDLE_TIMEOUT),
|
||||
publish_schemas: crate::gateway::schema_cache::PublishSchemaCache::new(),
|
||||
};
|
||||
let extracted: DecoyConfig = axum::extract::FromRef::from_ref(&state);
|
||||
assert!(matches!(extracted, DecoyConfig::Redirect { .. }));
|
||||
|
||||
@@ -84,11 +84,11 @@ and `held_session_drop_during_call_registration…` cover the CON-02 race
|
||||
asserts the sweep resolves it retryable; the original
|
||||
`connection_drop_fails_in_flight_calls_retryable_no_hang` stays green.
|
||||
One tolerated outcome note: the held-session race can also resolve via
|
||||
alkcall's write-failure path (`failed to write request frame`,
|
||||
`INTERNAL` — the mux dies between registration and write), which is
|
||||
prompt-but-not-retryable; that path is accepted in the race tests (the
|
||||
retryable assertion lives with the sweep and pre-drop tests where the
|
||||
call is guaranteed in-flight).
|
||||
alkcall's write-failure path — since alkcall CF-001 landed
|
||||
(`CallError::connection_closed`), that path now resolves
|
||||
retryable `CONNECTION_CLOSED` too, so the race tests assert
|
||||
retryable-only (updated in the alkcall-0.2 bump; the retryable
|
||||
assertion no longer needs the sweep-test carve-out).
|
||||
|
||||
Verification: cargo test 219 ok; cargo test --features wss 231 ok (3x
|
||||
flake check); cargo clippy --all-targets -- -D warnings (default;
|
||||
|
||||
@@ -367,14 +367,12 @@ async fn ws_close_mid_call_to_browser_op_aborts_call() {
|
||||
)
|
||||
};
|
||||
|
||||
let failed = conn
|
||||
.pending()
|
||||
.lock()
|
||||
.fail_all(alkcall::protocol::wire::CallError::new(
|
||||
"CONNECTION_CLOSED",
|
||||
"ws dropped",
|
||||
true,
|
||||
));
|
||||
let failed =
|
||||
conn.pending()
|
||||
.lock()
|
||||
.fail_all(alkcall::protocol::wire::CallError::connection_closed(
|
||||
"ws dropped",
|
||||
));
|
||||
assert!(failed.contains(&"hub-call-inflight".to_string()));
|
||||
|
||||
let result = tokio::time::timeout(Duration::from_millis(100), rx).await;
|
||||
|
||||
Reference in New Issue
Block a user