endpoint-libs 2.0: transport seam, peer identity, hooks - #41
Merged
Conversation
Before this, `WsMessage` was a re-export of `tungstenite::Message` whenever the `ws` feature was on, leaking a backend type through session, toolbox, subs, push, conn, server and client. The `#[cfg(not(feature = "ws"))]` inner enum was already the right shape; promote it to the canonical type. - `WireMessage` is now unconditional, `#[non_exhaustive]`, and carries the same five variants. `pub type WsMessage = WireMessage` keeps type positions compiling; it does NOT preserve tungstenite's inherent methods. - Conversions to/from tungstenite live only in the tungstenite backend module and are applied at two edges: the server upgrader's WsStream impl and the client's private stream helpers. - Swept client.rs alongside the modules named in the plan — it was an unlisted leak site (stream_send took tungstenite's Message directly). - `WireMessage::as_text` folds the old Text/Binary UTF-8 handling into one place; `is_close` replaces a common inherent-method call. - tungstenite's `Frame` variant degrades to an empty Binary rather than panicking: this crate never uses the low-level frame API, and an unexpected raw frame is not worth killing a live session over. The ws-wtx backend is deliberately untouched — it is dead code (compile_error deprecation, denylisted, no longer implements WsUpgrader). Verified: types / ws-core / full all build, ws-core pulls no tungstenite, clippy clean, 67 tests green (65 baseline + 2 new round-trip tests). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A bare SocketAddr only describes a TCP/TLS peer. Local transports (Unix
sockets, named pipes, XPC) identify peers by process and by verified code
identity, and that has to reach the request context for authz and logging.
- New `libs::peer`: PeerIdentity (Network/Local/Unknown, #[non_exhaustive]),
LocalPeer{pid,uid,attestation}, Attestation (None/Verified{mechanism,subject}).
- `WsConnection.address: SocketAddr` → `WsConnection.peer: PeerIdentity`, with
a #[deprecated] `address()` accessor returning a loopback placeholder for
non-network peers.
- `RequestContext` keeps `ip_addr` (populated via `peer.ip_addr()`, so logging
consumers keep compiling) and gains `peer` + `extensions`.
- Both types gain `extensions: Extensions`, a type-keyed map implemented here
rather than depending on `http`.
Extensions stores `Box<dyn CloneAny>` rather than `Box<dyn Any>` because
RequestContext derives Clone and consumers rely on it; values must therefore
be Clone, the same trade http::Extensions makes. Note the three explicit
derefs marked in that module: `Box<dyn CloneAny>` itself satisfies the blanket
impl's bounds, so `boxed.as_any()` / `self.clone_box()` silently resolve to
the box's own impl — the former never downcasts, the latter recurses into a
stack overflow. Both were caught by the tests in this commit.
Sites updated (the plan's grep inventory): server.rs construction + logging,
session.rs run/handle_message, headers.rs real-IP context, toolbox.rs
RequestContext::from_conn, and the ws-echo example (now the reference
migration for `conn.address` → `conn.peer`).
Verified: types / ws-core / full build, clippy clean, 72 tests green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
OpenAPI/AsyncAPI emission (PLAN-2.1.md) is an endpointgen feature that touches no runtime code — but only stays a MINOR release if the schema model can absorb new information without breaking. Today it cannot. This is the ~20-line insurance premium, and it is only free while 2.0 is already breaking. - #[non_exhaustive] on Type, Field, EnumVariant, EndpointSchema and EndpointErrorSchema. Type matters most: it is a plain public enum, so any future variant (a decimal with precision/scale, a constrained string, a format carrier) would otherwise be breaking. - New `meta` slot on Field and EndpointSchema, plus with_meta setters. Empty in 2.0; the 2.1 emitters read examples, constraints, tags and deprecation from it. `meta` is a MetaMap newtype rather than a bare BTreeMap<String, Value> because Field derives Hash/Ord/Eq and serde_json::Value implements none of them. The manual impls compare and hash by canonical JSON text, which is deterministic given BTreeMap's key ordering. Five tests pin the forward-compatibility contract 2.1 depends on: unknown future fields deserialize, absent meta defaults empty, meta round-trips including keys this version assigns no meaning to, empty meta never serializes (so 2.0 artifacts stay byte-identical and endpointgen --check sees no spurious drift), and Field's Hash/Ord derives still work with meta populated. Note for the lockstep release: #[non_exhaustive] breaks endpointgen's struct literal at definitions.rs:430 — fixed in that repo in this same session. Verified: types / ws-core / full build, clippy clean, 77 tests green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…Sockets
The point of 2.0: the session/dispatch/MCP machinery no longer assumes a
WebSocket underneath it.
- `libs::ws::transport`: `Transport` (STOLEN SHAPE from tarpc — a blanket alias
over Sink + Stream, so implementors never name it) and `TransportStream`,
which adapts any such transport into the object-safe `MessageStream`.
- `WsStream` trait renamed to `MessageStream` (alias kept) — it was never
WebSocket-specific, only named that way.
- `transport::framed` behind the new `framed-transport` feature: length-
delimited WireMessage framing over any byte stream. Format is documented in
the module and pinned by a test, because non-Rust peers must implement it.
Uses tokio-util's LengthDelimitedCodec but NOT tokio-serde: the kind byte
means the payload is not a bare serde value, so that layer buys nothing.
- Server: `serve_connection` (transport-agnostic entry) + `serve_with` +
`SessionListener`. `post_upgrade_connection` is now a thin WS-specific
wrapper over `serve_connection`; the upgrader/TLS/shard machinery is
untouched and feeds the same entry.
- Client: `WsClient::from_stream`, the mirror of `serve_connection`. The
private WsStream enum gains a Message variant — both additive.
The acceptance test (tests/transport_seam.rs) is the definition of done: over
an in-memory duplex pipe with no TCP socket, it round-trips a legacy
{method,seq,params} request through a real registered handler AND completes an
MCP initialize -> tools/list -> tools/call, driving the server through
serve_connection and the client through from_stream. examples/uds_echo.rs
proves the same on a real OS transport.
Two things the feature matrix caught that a `full`-only build would not:
ErrorCode/get_conn_id/get_log_id were imported under #[cfg(feature = "ws")],
so serve_connection did not compile for ws-core — exactly the configuration a
local transport uses. And the acceptance test silently ran zero tests until
ws-client was added to the feature set.
Known follow-up (non-breaking, deliberately not in scope): `from_stream` lives
behind `ws-client`, which pulls tungstenite. Splitting the client so it is
available from ws-core alone is additive and can come with the sibling crate.
Verified: types / ws-core / framed-transport / full all build, ws-core pulls no
tungstenite, clippy clean, 83 lib + 2 acceptance tests green, uds_echo runs.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
STOLEN SHAPE (tarpc request_hook): a Before/After pair around dispatch. This is where policy that is not endpoint-specific plugs in — mission-token verification above all — without the transport layer or the handlers knowing. - `libs::ws::hooks`: BeforeRequest (may reject, may attach claims to ctx.extensions), AfterRequest (observes RequestOutcome), OnConnect (refuses a peer once, rather than re-checking attestation on every request). - Registered via add_before_hook / add_after_hook / add_on_connect_hook; snapshotted into each spawned task so a slow hook cannot stall the session loop. Registration order, first error short-circuits. - Placement is after check_roles on both paths, so hooks only see calls already allowed to reach the endpoint. The subtle part is that the two paths need different error envelopes: the legacy path emits a WsResponseError carrying the hook's code and params, the MCP path emits jsonrpc_result(encode_tool_error(..)) so the caller sees a tool error with isError: true. Both are asserted. OnConnect runs before the connection is registered in `states`, so a refused peer never gets a slot and cannot be sent to. Five acceptance tests now cover: legacy round trip, MCP initialize/list/call, hook rejection on the legacy path (exact frame, code and params), hook rejection on the MCP path (tool error payload), claims flowing hook -> handler via extensions, AfterRequest observing both outcomes, and OnConnect admitting an attested peer. Note for anyone adding handlers: `check_handler` requires the struct to be named `Method<SchemaName>`, so the claims test needed its own Claims endpoint rather than a second handler on Echo. Verified: ws-core builds, clippy clean, 83 lib + 5 acceptance tests green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ansports - Version 2.0.0-alpha.1. - CHANGELOG entry listing the four breaking items and the additive surface, with the "wire protocols are unchanged" statement up front — that is the thing consumers most need to know. - docs/2.0-migration.md: exhaustive per-symbol table, including the WsMessage-alias caveat (type positions only, not tungstenite's inherent methods, with a replacement for each), the LocalSet requirement, and the known ws-client/from_stream limitation. - README "Transports (2.0)" section: the three entry points, the framed_json wire format for non-Rust peers, peer identity/attestation, and hooks. Also adds EndpointErrorSchema::new + with_message/with_fields — a gap in Phase 2b: the type was made #[non_exhaustive] without giving out-of-crate callers any way to build one. Found by compiling endpointgen against this release rather than by inspection. Verified: 83 lib + 5 acceptance tests green, clippy clean, and all four feature configurations build. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Makes the schema/handler/MCP machinery transport-agnostic, so the same server core runs over TCP+TLS+WebSocket (unchanged) and over local attested transports — Unix sockets, Windows named pipes, macOS XPC — implemented later in a sibling crate.
The wire protocols did not change. Legacy
{method, seq, params}frames and MCP JSON-RPC are byte-identical to 1.9, so deployed frontends need no changes.Paired with pathscale/EndpointGen#27 (lockstep, currently a draft). Merge this one first, publish 2.0, then unblock that one.
The proof
tests/transport_seam.rsis the definition of done. Over an in-memory duplex pipe with no TCP socket, it:{method, seq, params}request through a real registered handler,initialize→tools/list→tools/callon the same connection,serve_connectionand the client throughWsClient::from_stream.examples/uds_echo.rsdoes the same over a real Unix domain socket.Commits
4a3f96aWireMessagereplaces the tungstenite re-export; backend meets the crate at two edges5f1aad5PeerIdentity/Attestation/Extensionsreplace bareSocketAddr82687dc6ef9df3e58ae1fBeforeRequest/AfterRequest/OnConnecton both dispatch paths8313e7cBreaking changes (4)
Full per-symbol table in
docs/2.0-migration.md. Most consumers touch two or three lines.WsMessageis no longertungstenite::Message. The alias covers type positions only — not tungstenite's inherent methods (.into_text(),.into_data()). Replacements are documented per method.WsConnection.address: SocketAddr→.peer: PeerIdentity, with a#[deprecated]address()returning a loopback placeholder.WsStreamtrait →MessageStream(alias retained).#[non_exhaustive]— out-of-crate matches need a wildcard arm, construction goes through::new()+with_*.Why Phase 2b is in a breaking release
OpenAPI/AsyncAPI emission is an endpointgen feature that touches no runtime code — but it only stays a minor release if the schema model can absorb new information without breaking.
Typewas a plain public enum andFieldhad nowhere to put per-field examples or constraints. ~20 lines of#[non_exhaustive]plus a reservedmetaslot, free only while we were already breaking. Five tests pin the contract 2.1 depends on.It paid off immediately: compiling endpointgen against this branch surfaced exactly the two breaks it predicted, plus a genuine gap in my own work (
EndpointErrorSchemawas made non-exhaustive with no constructor — added).Notable findings
cargo test --all-featurescan never pass — and could not before this PR either.wsandws-wtxare mutually exclusive viacompile_error!. CI'scargo all-features testrespects the denylist. The plan's verification loop was wrong and is corrected.client.rs, a live tungstenite leak site the alias hid from the compiler.serve_connectionimportingErrorCode/get_conn_idunder#[cfg(feature = "ws")]— so it did not compile forws-core, exactly the config a local transport uses.ws-clientwas added; caught by checking the count, not the exit code.ExtensionsstoresBox<dyn CloneAny>soRequestContextstaysClone.Box<dyn CloneAny>satisfies its own blanket impl, soboxed.as_any()never downcasts andself.clone_box()recurses into a stack overflow. Three explicit derefs, commented at each site, caught by tests.Verification
-D warnings)types/ws-core/framed-transport/fullall build;ws-corepulls zero tungsteniteuds_echoruns end to endKnown limitation (not a blocker)
WsClient::from_streamsits behindws-client, which pulls tungstenite and rustls — so a sidecar speaking only XPC still compiles those in. Narrowing it is additive and non-breaking, deliberately deferred. Recorded in the migration guide.Not in scope
No platform attestation code (sibling crate), no wire-protocol changes, no mission-token semantics, no OpenAPI emission (that is 2.1 — see
PLAN-2.1.md).🤖 Generated with Claude Code