[FIX] Advertise and gate the protocol handshake on every route from one interceptor pair - #453
Open
justin13888 wants to merge 14 commits into
Conversation
…ix/protocol-headers-every-route-404
…ix/protocol-headers-every-route-404
…rceptor pair The design puts three request headers and three response headers on every route; the server declared the request half on four upload operations and never sent the response half at all. A Kynos ApiError has no response-header seam, so routes/upload.rs rode X-Capsule-Protocol-Min/-Max as problem extension members while capsule-sdk reads them from headers and got None. The seam is on the interceptor. negotiation.rs adds two: Negotiation, mounted router-wide outside the body-size limit, attaches the window to every response the chain produces, errors and short-circuits included; ProtocolGate, on a Group, reads the three request headers and refuses 426 or 400 before the handler runs. Both read the one UploadPolicy window, which gains the advisory min_client_build. The group holds the four upload session operations that enforced the handshake per route until now; routes/upload.rs loses that duplication, and the 426 keeps no window members in its body. openapi::describe_negotiation_headers files the three response headers under every response of every operation, since Kynos describes an interceptor's headers on success responses only. The test fixture's client sends the handshake on every request with raw() for its absence, and conformance.rs gains a document census and a document-driven wire census that pin the gated and exempt sets. Refs #404
boot::assemble built the upload policy from UploadPolicy::default() regardless of PROTOCOL_MIN and PROTOCOL_MAX, so a deployment that narrowed its window published one range on /.well-known/capsule/server-info and enforced another on POST /v1/upload. The policy is now built from the configured window, which is also what the negotiation interceptors advertise and refuse against. The new boot test reads both back through the surface. Refs #404
Every gated operation requires X-Capsule-Protocol and refuses without it. The shared reqwest client behind the generated REST client now carries that header and X-Capsule-Crypto-Suite as defaults, from the build's own constants, so the generated operations and the hand-written paths over the same transport send them with no per-call argument. protocol_headers() is public so the SDK's other transports can carry the same handshake from the same source. Refs #404
Deploying capsule with
|
| Latest commit: |
032b6af
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://4f43e8f8.capsule-22k.pages.dev |
| Branch Preview URL: | https://fix-protocol-headers-every-r.capsule-22k.pages.dev |
The ProtocolGate group now holds every non-exempt operation; GET /v1/version,
the four /.well-known/capsule/* records, the three /s/{opaque_id}* reads and
the two /d/{opaque_id}* guest deposits stay on the router and carry only the
response headers. The census in tests/conformance.rs asserts the gated set is
exactly the complement of the pinned ten, the coverage walk produces the 426
and 400 every gated operation now declares, and one representative route per
module is refused before a credential or body is read. The boot tests that
register and sign in send the handshake, as every client does.
Refs #404
net::http_builder installs X-Capsule-Protocol and X-Capsule-Crypto-Suite as default headers once; http_client builds it and dial_client adds its connect timeout on top of the same builder. AuthClient::new, the sync consumer, the generated client behind AuthenticatedClient and the CLI's version probe all obtain their client there, so no SDK request reaches a gated route without the handshake. The document now declares X-Capsule-Protocol required on every gated operation, which puts the protocol date first in each generated signature; the SDK's callers pass the build's own constant, the same value the transport sends, and the sync consumer maps the 426 the feed can now answer. Refs #404
reqwest::ClientBuilder is already must_use; clippy's double_must_use refuses the second attribute under the repo's -D warnings. Refs #404
5 tasks
… window threat-model/validation.md scopes the 426 to a write and says reads of any past version succeed. The gate is now two: ProtocolGate on the group of non-safe operations refuses a grammatical protocol date outside the window with 426, and ProtocolReadGate on the group of GET and HEAD operations admits any grammatical date and refuses only a missing or malformed handshake with 400. Because an interceptor's declaration is its type, a read now declares the 400 alone and no 426 it never renders; the census asserts exactly that, the coverage walk and the per-module table follow the split, the 413 loop and a new 401 case assert the window headers on refusals the gates did not make, and the feed's dead 426 arm leaves the SDK's sync consumer. Refs #404
PROTOCOL_MIN and PROTOCOL_MAX defaulted to the single day capsule-core speaks, collapsing the window a fresh deployment accepts to one date, and neither was checked for shape, so 2026-6-1 sorted before 2026-12-31 for the wrong reason. Both now default to the policy's year window, parse as strict YYYY-MM-DD dates, and may be equal. MIN_CLIENT_BUILD joins them, validated as MAJOR.MINOR.PATCH and handed to the upload policy, so the advisory X-Capsule-Min-Client-Build on every response is the operator's value rather than the crate default. With both validated at the boundary, an unencodable window value is a programming error and the response encoder says so. Refs #404
Every transport constructor that accepts a reqwest::Client names net::http_builder as its source; a client built any other way sends no protocol handshake and every gated route refuses it. Refs #404
Every gated route now refuses a request without X-Capsule-Protocol, and the hand-written web auth client sent none, so sign-in, registration, refresh and every authenticated call from the browser were refused. All five request builders send the protocol version this client is written against, restated from capsule_core::crypto::primitives::PROTOCOL_VERSION because the browser holds no Rust and the wasm surface does not export it. Refs #404
api.ts is hand-written, so the header could silently go missing again. A recording mock of the global fetch drives the five request builders and checks X-Capsule-Protocol equals PROTOCOL_VERSION on each, and PROTOCOL_VERSION is compared with the literal in capsule-core/src/crypto/primitives.rs read at test time, so the restated constant cannot drift from its source of truth. Refs #404
5 tasks
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.
Description
Issue #404: the protocol headers the design puts on every route were declared on four operations, and the response half was never sent at all —
X-Capsule-Protocol-Min/-Maxrode as problem-body extension members because a KynosApiErrorhas no response-header seam, whilecapsule-sdk/src/upload.rsreads them from headers and gotNoneevery time. The426recovery path was dead on both ends.The seam exists on the interceptor, not the error. This PR adds
capsule-server/src/negotiation.rswith two Kynos interceptors whose associated types are the declaration:Negotiation— router-wide, outside the body-size limit.Addsthe three response headers to every response the chain produces: a413, an extractor's400, the bearer scheme's401, the gate's426, a200.Short = Infallible; it cannot refuse.ProtocolGate— on a KynosGroup, which is how an exemption is spelled.Readsthe three request headers, refuses426 error.protocol.version_unsupportedfor a protocol outside[Min, Max]and400 error.request.malformedfor a missing/non-date protocol, a suite the inventory does not name, or a sidecar schema newer than the build indexes. Runs before the path, credential or body is looked at.Both read one
UploadPolicywindow, whichboot.rsnow builds fromPROTOCOL_MIN/PROTOCOL_MAX(it wasUploadPolicy::default()regardless of config — a deployment that narrowed its window published one range on/.well-known/capsule/server-infoand enforced another).X-Capsule-Min-Client-Buildis advertised from the policy (0.0.0= no cutoff announced; advisory perthreat-model/validation.md).routes/upload.rsloses its per-route handshake (ProtocolHeader,handshake*, fourProtocolUnsupportedvariants withprotocol_min/maxmembers, threeMalformedRequestvariants).POST /v1/uploadkeeps a body-level426for the manifest envelope's pin, without the window members. The fourProtocolRangeProblemrows for the upload operations leaveopenapi::EXTRAS; thealbum_lifecycle_oprow stays becauseroutes/ops.rs(outside this lane) still renders those members.openapi::describe_negotiation_headersfiles the three response headers under every response of every operation — Kynos describes an interceptor'sAddson success responses only — so the emitted document promises exactly what the wire carries.openapi.jsonis regenerated.Tests: the fixture's client is now a
support::Clientwrapper that sendsx-capsule-protocolon every request (as the SDK does), withraw()for the cases about its absence.tests/conformance.rsgains a document census (every response of every operation declares the three headers, required; the operations declaring the handshake are exactly the pinnedGATEDset and none of the design's nineEXEMPT) and a wire census driven by the document (every operation answers with the window; gated ones refuse426/400on it before auth; exempt ones never426).Every operation but the ten the design exempts is gated (49 of 59), by one of two gates: the 34 non-safe operations sit behind
ProtocolGate(a grammatical date outside the window is426), the 15 gatedGET/HEADoperations behindProtocolReadGate(admitted at any grammatical date — "reads of any past version succeed"; a future date on a read is admitted too, the design being silent), and a missing or malformedX-Capsule-Protocolis400 error.request.malformedon all 49. Because an interceptor's declaration is its type, a read declares the400alone and no426it never renders.GET /v1/version, the four/.well-known/capsule/*records, the three/s/{opaque_id}*reads and the two/d/{opaque_id}*guest deposits are mounted on the router and carry only the response headers.Config:
PROTOCOL_MIN/PROTOCOL_MAXdefault to the policy's year window (they defaulted to the single day core speaks, collapsing a fresh deployment's window to one date), parse as strictYYYY-MM-DDdates, and may be equal;MIN_CLIENT_BUILD(semver-validated, default0.0.0) reaches the policy and the header. The browser client (capsule-web/src/lib/api.ts) sendsX-Capsule-Protocolon all five request builders.SDK:
capsule_sdk::net::http_builder()/http_client()is the one place areqwestclient is built — rustls only,X-Capsule-ProtocolandX-Capsule-Crypto-Suiteinstalled once as default headers — andAuthClient::new(which everySession-driven path rides),SyncConsumer, the generatedrest::ClientbehindAuthenticatedClient,dial_client()and the CLI's version probe all obtain their client from it. The document declaresX-Capsule-Protocolrequired on every gated operation, so the spargen-generated signatures gain a leadingx_capsule_protocol: impl Into<String>argument and aParamsstruct for the two optional headers; the SDK's callers passcapsule_core::crypto::primitives::PROTOCOL_VERSION.Summary
capsule-server/src/negotiation.rs(new):ProtocolRequestHeaders,NegotiationResponseHeaders,NegotiationRejection,negotiate,Negotiation,ProtocolGate, unit tests.capsule-server/src/lib.rs:CodedProblems → Negotiation → BodySizeon the router;ProtocolGateon aGroupholding the four upload operations;ServerRouteralias;describe_negotiation_headerscall.capsule-server/src/routes/upload.rs: per-route handshake removed.capsule-server/src/openapi/mod.rs: the document walk; fourEXTRASrows removed.capsule-server/src/upload/policy.rs:min_client_build.capsule-server/src/boot.rs: policy from the configured window and client-build cutoff; tests.capsule-server/src/config.rs,capsule-server/.env.example: window defaults and date validation,MIN_CLIENT_BUILD.capsule-web/src/lib/api.ts:X-Capsule-Protocolon every request;capsule-web/src/lib/api.test.ts(new) asserts it on all five builders and pinsPROTOCOL_VERSIONtocapsule-core/src/crypto/primitives.rs.capsule-server/openapi.json: regenerated (OpenAPI 3.2.0).capsule-server/tests/{support/mod.rs,conformance.rs,upload.rs}: fixture client, census tests, header assertions.capsule-sdk/src/net.rs:protocol_headers(),http_builder(),http_client();dial_client()built on the same builder.capsule-sdk/src/{client.rs,auth.rs,sync.rs}: every transport fromnet::http_client();sync_feed/get_quotacallers pass the protocol date;client::tests::every_request_carries_the_protocol_handshake.capsule-cli/src/status.rs: the version probe overnet::http_client().capsule-docs/src/content/docs/design/api-surfaces.md: the carriage and the exempt list.Validation
All commands run inside the worktree (base
feat/server-binary-config-operator-commands-401at459e8af3, merged by merge commit).Round 4 (head
032b6af2, confirming round: the web handshake test):cd capsule-web && bun test src/lib/api.test.tsmise run check-web[format-check-web] Checked 77 files in 24ms. No fixes applied.·[lint-check-web] Checked 77 files in 51ms. No fixes applied.·[test-web] 65 pass / 0 fail·[build-web] ready built in 0.40smise run check-commits origin/feat/server-binary-config-operator-commands-401The one file added is a
bun:testfile undercapsule-web/; no Rust source changed since892f7575, whosecheck-rust/test-rustresults below stand for this head.Round 3 (head
892f7575, review round 1: read/write split, config validation, web client):mise run openapi-kynos+ censusopenapi: 3.2.0; 59 operations, 49 gated: 34 writes declare400+426, 15 reads declare400and no426; 0 responses missing a window headercargo nextest run -p capsule-server --no-fail-fastmise run check-webmise run check-rustunused importswarnings incapsule-core/src/library/receipts.rs:96mise run test-rustcargo nextest run --workspace1820 passed;-p capsule-core --features ffi729 passed;-p capsule-sdk --features ffi162 passedmise run check-docs-truthmise run lint-check-mdmise run check-docsmise run check-commits origin/feat/server-binary-config-operator-commands-401Round 2 (head
7526395b, the gate on every non-exempt operation;7526395bitself only drops a redundant#[must_use]clippy refused ond7d6f532):mise run openapi-kynos+ census of the fileopenapi: 3.2.0; 59 operations, 49 declaringX-Capsule-Protocolrequired (59 − 10 exempt), 0 responses missing a window headercargo check -p capsule-sdk -p capsule-cli --testssync_feed,get_quota,SyncFeedError::Status426)cargo nextest run -p capsule-server --no-fail-fast426/400on all 49,tests/sdk_client.rsover a socket,tests/binary.rsbyte-for-byte documentcargo nextest run -p capsule-sdk -p capsule-cli --no-fail-fastmise run check-rustd7d6f532:lint-check-rustfailed on oneclippy::double_must_useincapsule-sdk/src/net.rs(caused, fixed in7526395b); on7526395b: exit 0, every sub-task (format-check-rust, lint-check-rust, doc-check-rust, i18n-check, i18n-guard, openapi-check-kynos, architecture-check, license-check, translate-readme-check, build-rust, build-check-wasm, build-ffi, lint-check-ffi, gen-bindings, verify-examples); the same two pre-existingunused importswarnings incapsule-core/src/library/receipts.rs:96mise run test-rust7526395b: exit 0 —cargo nextest run --workspace1817 passed;-p capsule-core --features ffi729 passed;-p capsule-sdk --features ffi162 passed (also observed ond7d6f532: 1817 / 729 / 162)mise run check-docs-truthmise run lint-check-mdmise run check-docscargo fmt --all -- --checkmise run check-commits origin/feat/server-binary-config-operator-commands-401Round 1 (head
b4881d30, four gated operations), kept for the record:cargo nextest run -p capsule-server --no-fail-fastcargo nextest run -p capsule-sdk --no-fail-fastmise run check-rustunused importswarnings incapsule-core/src/library/receipts.rs:96— pre-existing, file untouched by this PRmise run test-rustmise run check-docs,check-docs-truth,lint-check-md,cargo fmt --check,check-commitsOne environmental failure during the run: the shared scratch volume filled (
ENOSPC) between the first server suite and the document regeneration; classifiedunavailable, cleared without deleting anything, and every gate above was run after it.Risks and rollout
X-Capsule-Protocolnow refuse it with400 error.request.malformed, and every gated write refuses an out-of-window date with426; reads are admitted at any date. This is the fail-closed contract the design states and server: the protocol headers the design puts on every route are on four operations #404 was filed against. The in-tree browser client sends the header (round 3); there is no released third-party client.x_capsule_protocolfirst argument; aParamsstruct where there was none). In-workspace callers are updated; an out-of-tree caller ofcapsule_sdk::restpassesPROTOCOL_VERSION. The hand-written SDK paths (upload,albums,verify,directory,fetch) are unchanged in shape.426no longer carriesprotocol_min/protocol_maxin the body; it carries them onX-Capsule-Protocol-Min/-Max, which is where the SDK already reads them. The missing-header400on the upload operations carrieserror.request.malformedinstead oferror.upload.malformed_request.404/405from the router itself) carries no window: interceptors run per operation, after routing. Kynos limitation; recorded.openapi.jsonregenerates from the router either way.Related Issues
Closes #404. Refs #451 (the follow-up filed when this lane stopped at its manifest; the widening landed here after the orchestrator widened the manifest — see "Decisions taken" 16). Forecast collision: #430 (
chore/retire-capsule-wire-400) removescapsule-wire; this tree adds no use of it (decision 23).Decisions taken
Unresolved review notes
(none)
Contributor Checklist