Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
3677a0d
feat(middleware): define HTTP response pre-return interface
pimlock Sep 1, 2026
70d3a74
docs(middleware): clarify HTTP response interface
pimlock Sep 1, 2026
8982b5f
refactor(middleware): align response result actions
pimlock Sep 1, 2026
042c5a1
refactor(middleware): expose response reason codes
pimlock Sep 1, 2026
206df4a
refactor(middleware): share session end reasons
pimlock Sep 1, 2026
185bb52
feat(middleware)!: finalize HTTP response pre-return contract
pimlock Sep 1, 2026
2b3e948
docs(middleware): describe skip as opting out of inspection
pimlock Sep 1, 2026
5e1ae8f
feat(middleware): add body-phase block_delivery and skip_remaining ac…
pimlock Sep 2, 2026
840db08
refactor(middleware): share HTTP body leaf messages across directions
pimlock Sep 2, 2026
0d1c6f3
refactor(middleware): keep HTTP body leaf messages response-specific
pimlock Sep 2, 2026
c221d8d
docs(middleware): simplify response proto comments
pimlock Sep 2, 2026
fd57519
fix(middleware): reject undispatched response bindings
pimlock Sep 2, 2026
590b193
docs(middleware): simplify phase field comment
pimlock Sep 2, 2026
97da678
refactor(middleware): rename HTTP response preflight result
pimlock Sep 3, 2026
66babd2
feat(middleware): add HTTP response trailer results
pimlock Sep 3, 2026
02f3984
docs(middleware): define response block delivery
pimlock Sep 3, 2026
b28fd3e
docs(middleware): define stage-local response body modes
pimlock Sep 3, 2026
49a8b77
docs(middleware): define final response body units
pimlock Sep 3, 2026
4e4ed54
docs(middleware): define streaming response deferral
pimlock Sep 3, 2026
44e1787
docs(middleware): defer whole-body accumulation timeout
pimlock Sep 3, 2026
60e28b7
docs(middleware): align response result diagnostics
pimlock Sep 3, 2026
c8fc5dd
test(middleware): cover upstream WebSocket disconnect
pimlock Sep 4, 2026
e53ff16
fix(middleware): keep response streams unit-local
pimlock Sep 4, 2026
c9162bd
docs(middleware): trim disconnect compatibility note
pimlock Sep 4, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/branch-checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -158,12 +158,14 @@ jobs:
cargo fmt --all -- --check
cargo fmt --manifest-path e2e/rust/Cargo.toml --all -- --check
cargo fmt --manifest-path examples/governance-interceptor/Cargo.toml --all -- --check
cargo fmt --manifest-path examples/supervisor-middleware-content-guard/Cargo.toml --all -- --check

- name: Lint
run: |
cargo clippy --workspace --all-targets -- -D warnings
cargo clippy --manifest-path e2e/rust/Cargo.toml --all-targets -- -D warnings
cargo check --manifest-path examples/governance-interceptor/Cargo.toml --all-targets
cargo check --manifest-path examples/supervisor-middleware-content-guard/Cargo.toml --all-targets

- name: Test
env:
Expand Down
33 changes: 30 additions & 3 deletions crates/openshell-core/src/middleware.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,17 @@ use tokio::sync::mpsc;
use tonic::{Request, Response, Status};

use crate::proto::{
HttpHeader, HttpRequestEvaluation, HttpRequestResult, HttpRequestTarget, MiddlewareManifest,
RequestContext, SupervisorMiddlewarePhase, ValidateConfigRequest, ValidateConfigResponse,
WebSocketSessionEvent, WebSocketSessionEventResult,
HttpHeader, HttpRequestEvaluation, HttpRequestResult, HttpRequestTarget, HttpResponseEvent,
HttpResponseEventResult, MiddlewareManifest, RequestContext, SupervisorMiddlewarePhase,
ValidateConfigRequest, ValidateConfigResponse, WebSocketSessionEvent,
WebSocketSessionEventResult,
};

/// Transport-neutral result stream for one HTTP response middleware stage.
pub type HttpResponseResultStream = Pin<
Box<dyn tokio_stream::Stream<Item = Result<HttpResponseEventResult, Status>> + Send + 'static>,
>;

/// Transport-neutral response stream for one WebSocket middleware stage.
pub type WebSocketResponseStream = Pin<
Box<
Expand Down Expand Up @@ -47,6 +53,15 @@ pub trait SupervisorMiddlewareEndpoint: Send + Sync {
&self,
requests: mpsc::Receiver<WebSocketSessionEvent>,
) -> Result<WebSocketResponseStream, Status>;

async fn open_http_response_pre_return(
&self,
_requests: mpsc::Receiver<HttpResponseEvent>,
) -> Result<HttpResponseResultStream, Status> {
Err(Status::unimplemented(
"middleware does not implement HTTP response pre-return evaluation",
))
}
}

/// Borrowed request state exposed to one in-process middleware invocation.
Expand Down Expand Up @@ -242,6 +257,18 @@ pub trait InProcessMiddleware: Send + Sync {
"middleware does not implement WebSocket sessions",
))
}

/// Open one HTTP response pre-return stream.
///
/// Request-only implementations may keep the default unsupported response.
async fn open_http_response_pre_return(
&self,
_requests: mpsc::Receiver<HttpResponseEvent>,
) -> std::result::Result<HttpResponseResultStream, Status> {
Err(Status::unimplemented(
"middleware does not implement HTTP response pre-return evaluation",
))
}
}

/// Default timeout for one supervisor middleware RPC.
Expand Down
128 changes: 128 additions & 0 deletions crates/openshell-supervisor-middleware/src/headers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ pub const MAX_HEADER_MUTATION_BYTES: usize = 32 * 1024;
pub enum HeaderAuthority {
Request,
Response,
ResponseTrailers,
}

#[derive(Debug, Clone, PartialEq, Eq)]
Expand All @@ -30,6 +31,7 @@ pub enum HeaderMutationError {
InvalidExistingAction,
MissingExistingAction { name: String },
UnsupportedExistingAction,
AbsentTrailerName { name: String },
Empty,
}

Expand All @@ -47,6 +49,7 @@ impl HeaderMutationError {
Self::InvalidExistingAction => "header_mutation_invalid_existing_action",
Self::MissingExistingAction { .. } => "header_mutation_missing_existing_action",
Self::UnsupportedExistingAction => "header_mutation_unsupported_existing_action",
Self::AbsentTrailerName { .. } => "trailer_mutation_absent_name",
Self::Empty => "header_mutation_empty",
}
}
Expand Down Expand Up @@ -104,6 +107,10 @@ impl std::fmt::Display for HeaderMutationError {
"middleware returned unsupported on_existing action"
)
}
Self::AbsentTrailerName { name } => write!(
formatter,
"middleware cannot create absent response trailer '{name}'"
),
Self::Empty => write!(formatter, "middleware returned an empty header mutation"),
}
}
Expand Down Expand Up @@ -133,6 +140,15 @@ pub fn apply(
Some(header_mutation::Operation::Write(write)) => {
let name = validate_name(&write.name)?;
validate_authority(authority, MutationKind::Write, &write.name, &name)?;
if authority == HeaderAuthority::ResponseTrailers
&& !existing_headers
.iter()
.any(|existing| existing.name.eq_ignore_ascii_case(&name))
{
return Err(HeaderMutationError::AbsentTrailerName {
name: write.name.clone(),
});
}
if is_connection_nominated(connection_nominated_headers, &name) {
return Err(HeaderMutationError::HopByHop {
name: write.name.clone(),
Expand Down Expand Up @@ -229,6 +245,7 @@ fn validate_authority(
is_response_protected(normalized_name)
|| (kind == MutationKind::Write && is_response_remove_only(normalized_name))
}
HeaderAuthority::ResponseTrailers => is_response_protected(normalized_name),
};
if protected {
return Err(HeaderMutationError::Protected {
Expand Down Expand Up @@ -650,4 +667,115 @@ mod tests {
}
}
}

#[test]
fn empty_response_trailers_accept_pass_through() {
assert_eq!(
apply(HeaderAuthority::ResponseTrailers, &[], &[], &[]),
Ok(Vec::new())
);
}

#[test]
fn response_trailer_pass_through_preserves_fields_and_order() {
let existing = [
header("x-checksum", "one"),
header("x-trace", "middle"),
header("x-checksum", "two"),
];

let updated = apply(HeaderAuthority::ResponseTrailers, &existing, &[], &[])
.expect("empty mutation list");

assert_eq!(updated, existing);
}

#[test]
fn response_trailer_mutations_modify_remove_and_preserve_order() {
let existing = [
header("x-checksum", "one"),
header("x-remove", "gone"),
header("x-trace", "middle"),
header("x-checksum", "two"),
];

let updated = apply(
HeaderAuthority::ResponseTrailers,
&existing,
&[],
&[
write("X-Checksum", "replacement", ExistingHeaderAction::Overwrite),
remove("X-Remove"),
write("X-Trace", "last", ExistingHeaderAction::Append),
],
)
.expect("permitted response trailer mutations");

assert_eq!(
updated,
vec![
header("x-trace", "middle"),
header("x-checksum", "replacement"),
header("x-trace", "last"),
]
);
}

#[test]
fn response_trailer_write_cannot_introduce_an_absent_name() {
let existing = [header("x-checksum", "one")];
let error = apply(
HeaderAuthority::ResponseTrailers,
&existing,
&[],
&[write(
"X-New-Trailer",
"value",
ExistingHeaderAction::Overwrite,
)],
)
.expect_err("absent response trailer name");

assert_eq!(
error,
HeaderMutationError::AbsentTrailerName {
name: "X-New-Trailer".into()
}
);
}

#[test]
fn response_trailer_removal_of_an_absent_name_is_a_noop() {
let existing = [header("x-checksum", "one")];
let updated = apply(
HeaderAuthority::ResponseTrailers,
&existing,
&[],
&[remove("X-Missing")],
)
.expect("absent response trailer removal");

assert_eq!(updated, existing);
}

#[test]
fn response_trailer_protected_fields_cannot_be_mutated() {
for mutation in [
write("Content-Length", "10", ExistingHeaderAction::Overwrite),
remove("Set-Cookie"),
] {
let error = apply(
HeaderAuthority::ResponseTrailers,
&[
header("content-length", "5"),
header("set-cookie", "session=upstream"),
],
&[],
&[mutation],
)
.expect_err("protected response trailer mutation");

assert!(matches!(error, HeaderMutationError::Protected { .. }));
}
}
}
Loading
Loading