feat(parser_http_server): HTTP+JSON pivot for TVC public ingress - #450
feat(parser_http_server): HTTP+JSON pivot for TVC public ingress#450pepe-anchor wants to merge 10 commits into
Conversation
…446) ## Why Two host binaries have to serve the same Turnkey JSON envelope to the same client. - `parser_gateway`: REST in front, gRPC to `parser_grpc_server` behind. Non-TEE local dev and CI, so it emits a mock `bootProof`. - `parser_http_server` (#450): HTTP+JSON inside the enclave, calling `parser_app` in process, no gRPC hop. This is what gets deployed to the TVC, because Cloudflare in front of `app-<uuid>.turnkey.cloud` rejects gRPC with 403. It emits a real attested `bootProof`. Different transports, different trust levels, one wire contract. The Go [`visualsign-turnkeyclient`](https://github.com/anchorageoss/visualsign-turnkeyclient) and the wallet integrators behind it cannot tell the two apart, and must not be able to. Two features also land in that envelope during PRS-581: `bootProof` (#337) and `intermediateOutput` (#414). If the definition stays inline in `parser_gateway`, the pivot copies it, and every envelope change from here on gets made twice and kept in sync by hand. The unmerged x402 branch had already grown its own third copy. So: one home, two importers, and the `bootProof` difference becomes a parameter instead of a fork. Downstream, #450, #451 and #452 build on `parser_http_server`; #449 builds on `parser_gateway`. Both sides import the envelope from here, which is also what shrinks the PR #304 rebase from "reconcile two envelope definitions" to "add a module". ## What - `host_primitives::turnkey` becomes the single home for the Turnkey request/response envelope, as the union of both existing definitions. - `bootProof` is now an injected value rather than a hardcoded mock: `error_response(msg, boot_proof)`. `parser_gateway` keeps its stable local-dev mock through a local shim; the enclave pivot supplies a real attested one. - Construction moved with the types: `success_response` sits next to `error_response`, so `parse_handler` no longer assembles the success envelope field by field in the gateway. - `parser_gateway` imports the shared types and keeps the four `MOCK_BOOT_PROOF_*` constants and the tests that are about its own mock and top-level wire shape. Three tests that would have duplicated coverage now living in `host_primitives::turnkey` moved there instead of being maintained in both places. - `intermediate_output` gained `serde(default)` alongside its existing `skip_serializing_if`. Without it the omitted key serialized fine but failed to deserialize, so a response could be written and not read back. Nothing in tree deserializes a response today (both binaries only emit one), which is why no test caught it. The client-direction derives are there so the envelope is symmetric for the out-of-tree clients that do read it, and the new round-trip test is what keeps it that way. Happy to drop those derives and the `default` until something in tree needs them, if you'd rather not carry them. No wire change. The gateway's JSON output is byte-identical. ## Test evidence ``` cargo test -p host_primitives -p parser_gateway host_primitives: 5 passed parser_gateway: 11 passed cargo fmt --all -- --check: clean make -C src lint: clean ``` The gateway test count moves from 13 to 11 because three tests relocated to `host_primitives::turnkey`, where the types they cover now live: the six-key `bootProof` assertion (`boot_proof_wire_shape_is_exactly_six_camel_case_keys`), the empty `intermediateOutput` omission check, and the Solana chain-metadata discriminator test. `host_primitives` goes 4 to 5 with the new round-trip test. No coverage was dropped. The two regression tests that matter both still pass with unchanged constant values: `mock_boot_proof_matches_production_wire_shape`, `error_response_carries_mock_boot_proof`. The "no wire change" claim was checked rather than assumed: a throwaway test reconstructed the pre-refactor struct definitions verbatim from base and byte-compared `serde_json` output against the new `host_primitives::turnkey` types across success, error, signature-present, and intermediate-output-present cases. All identical. One trap worth recording: the x402 branch's `TurnkeyResponseWrapper` had no `rename_all = "camelCase"`, which the gateway's inline struct did have. Adding `boot_proof` without it would have serialized as `boot_proof` instead of `bootProof` and silently broken the wallet contract. The six-key wire-shape test catches it. ## Rollback Revert the commits. No deploy, no migration, no wire change, so a revert restores the previous state exactly. ## Linear PRS-581 Stack position: base of the PRS-581 stack. #337, #414. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Turnkey's TVC ingress is HTTP only (Cloudflare in front of app-<uuid>.turnkey.cloud rejects gRPC with 403), so switching the parse path onto the pivot needs a binary that speaks HTTP+JSON natively and calls parser_app::routes::parse in-process. Open v1 and v2 routes only. No payment enforcement, no auth, no proto change: those are separate PRs on top. What this PR does own is the three seams they plug into, so they can be written in parallel without colliding: handlers take raw Bytes (an X-Stamp signature covers the exact request bytes, and a Json<T> round-trip would invalidate it), bootProof comes from a BootProofSource trait, and the manifest fields are already borsh-encoded the way the Go verifier reads them. The integration test fails fast if the server dies before binding. It polls try_wait alongside the port, because wait_until_port_is_bound loops forever: a pivot built with --features vsock looks for the absolute in-enclave key path, exits at startup, and would otherwise hang CI instead of failing it. Co-Authored-By: Claude <noreply@anthropic.com>
The TVC deployment-details step hardcoded parser_app, so the only binary that ever got a reproducible digest and a paste-ready deploy block was parser_app. The pivot we are switching to (parser_http_server) needs the same treatment before it can be deployed or probed. Generalize the extraction over the matrix target and add the two missing images. QOS version stays at 0.12.0 here; the bump is PRS-581 PR 7. Co-Authored-By: Claude <noreply@anthropic.com>
The matrix gained parser_grpc_server and parser_http_server, but the root Makefile had no rule for either, so both legs died at the Build step before Docker ran. images/parser_grpc_server/Containerfile was missing entirely. parser_grpc_server mirrors parser_gateway rather than parser_app: the crate has no [features] table and is not a TVC pivot, so vsock/CHAIN_FEATURES are deliberately omitted. Chains still link in through parser_app's default features, since --no-default-features applies to the package being built, not to its path dependencies. Also fixes a lost update on the release body. Three legs now run the deployment-details step concurrently, each doing gh release view then gh release edit with a full-body overwrite. Per-target sentinels keep a leg's own block idempotent across re-runs but do not serialize legs, so whichever edit landed last dropped the others' sections. Retry with a fresh read and verify the write stuck. Co-Authored-By: Claude <noreply@anthropic.com>
…esponse The pivot was written against host_primitives::turnkey as of the fork point, before #446 grew a shared success_response helper. Rebasing onto main left handle_parse assembling the wrapper field by field, which is the second copy of the wire contract that #446 existed to remove. No wire change: success_response fills the same three fields and sets error: None, so the emitted JSON is byte-identical. The bootProof six-key assertions in host_primitives, parser_http_server and the http_server integration test all still pass. Co-Authored-By: Claude <noreply@anthropic.com>
7fe0208 to
7bc3e65
Compare
There was a problem hiding this comment.
Pull request overview
Adds an in-process HTTP/JSON ingress for Turnkey TVC, including boot-proof extension points and deployment packaging.
Changes:
- Adds open v1/v2 parse routes and health endpoint.
- Adds static boot-proof generation and HTTP integration coverage.
- Extends container builds and stagex release metadata.
Reviewed changes
Copilot reviewed 11 out of 12 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
src/parser/http-server/src/main.rs |
Implements HTTP ingress and parsing. |
src/parser/http-server/src/boot_proof.rs |
Adds boot-proof abstraction and manifest encoding. |
src/parser/http-server/Cargo.toml |
Configures server dependencies and features. |
src/parser/http-server/build.rs |
Injects build version metadata. |
src/integration/tests/http_server.rs |
Tests HTTP routes and response shape. |
src/integration/Cargo.toml |
Adds HTTP test dependency. |
src/Cargo.toml |
Registers the new workspace crate. |
src/Cargo.lock |
Locks new crate dependencies. |
Makefile |
Adds server image targets. |
images/parser_http_server/Containerfile |
Packages the HTTP pivot. |
images/parser_grpc_server/Containerfile |
Packages the gRPC server. |
.github/workflows/stagex.yml |
Builds images and publishes deployment notes. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
… reactor Review feedback on the pivot, four separate defects in the HTTP surface. axum rejects an oversized body, an unmatched route and a disallowed method before any handler runs, so those responses went out as bare axum errors with no bootProof at all. That contradicts the contract this PR states: every response, success or error, carries one. The 413 case goes through a middleware, because DefaultBodyLimit rejects while reading the body and no handler ever sees it. 404 and 405 deliberately do not: handle_parse legitimately returns 404 itself for Code::NotFound, carrying the parser's real error message, and a middleware keyed on status alone sitting in front of handler output cannot tell that apart from an unmatched route. It would silently replace a real parse failure with "not found". Those two go through Router::fallback and method_not_allowed_fallback, which axum invokes only when no handler ran. parse is CPU bound, not I/O bound. Running it directly on the async task pins a Tokio worker thread per concurrent request and starves everything else on that worker, including the health check Turnkey polls, on a 1 to 2 vCPU replica. Wrapping it in block_in_place matches what parser_app's Processor already does around this same call on the vsock path. serde_json's Display for a type mismatch embeds the offending value, so the old deserialize error reflected up to the full request body back to an unauthenticated caller. The client now gets a fixed message and the detail goes to stderr, alongside new logging on the three internal error paths that previously failed silently. The crate level clippy allows for unwrap, expect and panic were a TODO(#231) placeholder. The sites they covered are gone rather than exempted: the ephemeral key load propagates its error, and SIGTERM registration falls back to ctrl-c instead of panicking. The integration test grows a request timeout so a server that accepts a connection but never answers fails instead of hanging, asserts the envelope on the 400, 404, 405 and 413 paths, and its generated key workdir is now gitignored so a P256 private key cannot linger on a shared runner. Co-Authored-By: Claude <noreply@anthropic.com>
The pivot's default feature set was written to mirror parser_app's, and its own comment says so, but it was missing near. A NEAR parse request would therefore succeed against parser_app and fail against the pivot, which is a silent behaviour difference between the two binaries rather than a deliberate trim. The Containerfile default moves with it, since that is where the image's feature list is pinned and it carries the same "mirrors Cargo.toml" claim. Chain specific deploys can still trim the set to shrink the image and the attestation surface. Co-Authored-By: Claude <noreply@anthropic.com>
The Go verifier borsh-deserializes both qosManifestB64 and qosManifestEnvelopeB64, and the attestation doc's user_data is the sha256 of the borsh manifest bytes. Nothing asserted that, so swapping the encoding to the JSON that actually sits on disk, or introducing a field whose encoding is not order stable, would break verification with every test still green. Built field by field rather than through ManifestEnvelope's Default, which only exists behind qos_core's mock feature. That feature cannot be unified in one build graph with this crate's vsock feature, since qos_core's own compile_error forbids vm and mock together, so pulling it in as a dev dependency broke clippy and test runs configured for the production feature set. Every field is a plain public value, so no derive is needed. Also hoists the base64 engine import to the module top and folds the two duplicated encode paths into one helper. Co-Authored-By: Claude <noreply@anthropic.com>
… exist The generated deploy notes told an operator that the v2 route is TVC-enforced when GATEWAY_SIGNING_PUBKEY_HEX is set, and that parser_gateway performs x402 verify and settle. Neither is true yet. That variable is read nowhere in src, v2 currently routes to the same open handler as v1, and there is no payment code in the tree at all. Notes that overstate what is enforced are worse than notes that say nothing, because the reader has no way to tell. Both notes now describe the scaffolding as scaffolding and point at the follow-up that implements it. Also softens the comment on the release-note retry loop. It claimed the retry plus read-back stops legs clobbering each other's block, but it is best effort rather than a lock: it catches our own write being clobbered before we read it back, while a later leg writing from a stale read can still land after our read-back succeeded and drop our block. Blast radius is a cosmetic release body, not the build or deploy path, and a manual re-run restores it. Co-Authored-By: Claude <noreply@anthropic.com>
Generated by /finish P3 iteration 0. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 12 out of 13 changed files in this pull request and generated 3 comments.
Suppressed comments (1)
src/parser/http-server/src/main.rs:355
- This only exercises
parse_envelopedirectly, so it still passes if either route changes its extractor from rawBytestoJson<TurnkeyRequestWrapper>. The handler-level compile regression described in the resolved review thread is absent from the current diff; restore a test that callsparse_v1(State(state), Bytes::from_static(...))(and/or routes a raw body through the router) so the X-Stamp extension seam is actually pinned.
fn envelope_is_parsed_from_raw_bytes_not_reserialized() {
// A later PR verifies an X-Stamp signature over the exact request
// bytes. If a handler ever takes `Json<T>` and re-serializes, the
// bytes change (key order, whitespace, unicode escaping) and every
// stamp fails. Locking the seam here means that PR adds one call and
Generated by /finish P3 iteration 1. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 12 out of 13 changed files in this pull request and generated no new comments.
Suppressed comments (2)
Previously missed (2) — in code that hasn't changed since the last review.
src/parser/http-server/src/main.rs:135
- Logging
{e}still records attacker-controlled values from the unauthenticated request: as noted above, a serde type mismatch can include the offending string verbatim, allowing each 400 to write nearly 64 KiB of request data (including transaction contents) into enclave logs. Log only bounded metadata such as the error location/category while keeping the client message generic.
eprintln!("invalid request body: {e}");
.github/workflows/stagex.yml:140
- Including
parser_gatewayhere generates the same paste-readytvc deploy createinstructions used for enclave pivots, even though the role note identifies it as host-side. Its image contains only the gateway, which defaultsGRPC_ADDRto localhost and reports/healthas unavailable without a separate gRPC backend (src/parser/gateway/src/main.rs:69-105,228-237), so following these generated instructions creates an unhealthy TVC deployment. Keep publishing its image, but restrict TVC deployment instructions to the actual pivot binaries (or give the gateway a separate non-TVC note).
if: matrix.target.name == 'parser_app' || matrix.target.name == 'parser_gateway' || matrix.target.name == 'parser_http_server'
Why
Turnkey's TVC public ingress is HTTP only: Cloudflare in front of
app-<uuid>.turnkey.cloudrejects gRPC with 403 (verified 2026-05-16). So switching the parse path onto the pivot needs a binary that speaks HTTP and JSON natively and callsparser_app::routes::parsein process, instead of gRPC over vsock.This is the keystone of the PRS-581 stack. Two PRs are built directly on it in parallel (in-enclave X-Stamp auth, real NSM boot proof), so as much as it is a feature, it is the set of extension points those need.
What
Open v1 and v2 routes only, plus
/health. No payment enforcement, no auth, no proto change in this PR.The three seams it owns, and why each is shaped that way:
Bytes, neverJson<T>. An X-Stamp signature covers the exact request bytes; letting serde deserialize and re-serialize changes them (key order, whitespace) and invalidates every signature.parse_envelope(&[u8])deserializes while the caller keeps the untouched slice. A test pins this so a future refactor cannot quietly undo it.BootProofSourcetrait with aStaticBootProofimplementation, so the NSM-backed one is a new file rather than a rewrite.awsAttestationDocB64is an empty string here: empty, never faked, because a strict verifier must reject an unattested response outright./qos.manifestholds JSON at qos rev365ba7ed, butqosManifestB64andqosManifestEnvelopeB64are borsh bytes: the Go verifier borsh-deserializes both, and the attestation doc'suser_dataissha256(borsh(manifest)). Base64-ing the file bytes would produce fields nothing can verify. Verified againstvisualsign-turnkeyclientmanifest/parser.go.Also threads
include_intermediate_outputthrough for parity with the gateway's REST shape (#414). Every response, success and error, carriesbootProof.The branch also carries the two stagex commits from PRS-581 PR 02, so the pivot has an image to deploy: the matrix builds
parser_http_serverandparser_grpc_server, the root Makefile gains the rules both legs need,images/parser_grpc_server/Containerfileis added, and the concurrent release-note writes gain a best-effort retry so the legs mostly stop clobbering each other's deployment blocks (see the correction below, the mechanism is not a lock).Rebase onto main after #446
#446 merged, so this branch is rebased onto current
mainand the merge commit is flattened away. Three commits now, no merge.The rebase itself was textually clean: this branch never edited
host_primitives/src/turnkey.rsorparser/gateway/src/main.rs, it only imports from them.What it did leave behind was a semantic mismatch. The pivot was written against
host_primitives::turnkeyas of the fork point, which haderror_responsebut not yetsuccess_response(that landed in87816dff, after the fork). So after the rebasehandle_parsewas still assembling the success wrapper field by field, which is exactly the second copy of the wire contract #446 existed to delete. Fixed in its own commit: the success path now goes throughsuccess_response, matching whatparser_gatewaydoes.No wire change.
success_responsefills the same three fields and setserror: None, so the emitted JSON is byte-identical, andbootProofstays camelCase with exactly its six camelCase keys. Three separate tests pin that and all still pass (host_primitives::turnkey::boot_proof_wire_shape_is_exactly_six_camel_case_keys,parser_http_server'sstatic_boot_proof_has_the_six_keys_and_a_real_ephemeral_pubkey, and thehttp_serverintegration test's key-set assertion on both the success and the 400 response).Review pass, four more commits
A pre-push review round on top of the rebase. Nothing here changes the wire
shape or the three seams the stacked PRs plug into.
Every response now really does carry
bootProof. The PR claimed that andwas wrong for three statuses. axum rejects an oversized body, an unmatched
route and a disallowed method before any handler runs, so 413, 404 and 405
went out as bare axum errors with no envelope. 413 is fixed with a middleware,
since
DefaultBodyLimitrejects while reading the body and no handler sees it.404 and 405 deliberately do not share that middleware:
handle_parselegitimately returns 404 itself for
Code::NotFoundwith the parser's realmessage, and a middleware keyed on status alone cannot tell that apart from an
unmatched route, so it would replace a genuine parse failure with "not found".
Those two go through
Router::fallbackandmethod_not_allowed_fallback,which axum invokes only when no handler ran. The integration test asserts the
envelope on all four error paths now.
The parse call no longer blocks the reactor.
parseis CPU bound, sorunning it on the async task pinned a Tokio worker per concurrent request and
would starve the health check Turnkey polls on a 1 to 2 vCPU replica.
block_in_placematches whatparser_app'sProcessoralready does aroundthis same call on the vsock path.
Error messages stopped reflecting request bodies.
serde_json'sDisplayfor a type mismatch embeds the offending value, so a malformed request echoed
up to the full body back to an unauthenticated caller. The client gets a fixed
message; the detail goes to stderr, along with new logging on three internal
error paths that previously failed silently.
The
TODO(#231)clippy exemptions are gone, not deferred: the ephemeralkey load propagates its error and SIGTERM registration falls back to ctrl-c,
so the crate-level
unwrap/expect/panicallows could be deleted outright.NEAR is compiled in. The default feature set said it mirrored
parser_app's and did not, so a NEAR request would have succeeded againstparser_appand failed against the pivot. That was a silent divergence, not adeliberate trim.
Two correctness fixes to the notes this branch generates. The release notes
told an operator that
/v2is TVC-enforced whenGATEWAY_SIGNING_PUBKEY_HEXis set and that
parser_gatewayperforms x402 verify and settle. Neither istrue yet: that variable is read nowhere in
src,/v2routes to the same openhandler as
/v1, and there is no payment code in the tree. Both notes nowdescribe the scaffolding as scaffolding.
Correction to
ac9e5a47's commit title. It says "serialize release-notewrites" and the mechanism does not serialize. The retry plus read-back is best
effort: it catches this leg's own write being clobbered before it reads back,
but a later leg writing from a stale read can still land after that read-back
succeeded and drop this leg's block. The in-file comment now says so plainly.
Blast radius is a cosmetic release body, not the build or deploy path, and a
manual re-run restores it. Left as best effort rather than adding If-Match or
per-target release assets, which is a real design change and out of scope here.
Not amending the commit message, since this branch has stacked children.
Also pins the borsh encoding of
qosManifestB64andqosManifestEnvelopeB64with a round-trip test. Nothing asserted it, so swapping to the JSON that
actually sits on disk would have broken Go-side verification with every test
still green.
Test evidence
Those last two mattered: they were newly broken and are now fixed. A
qos_coredev-dependency added for the borsh round-trip test pulled in themockfeature, which cannot be unified in one build graph with this crate'svsockfeature (qos_core's owncompile_error!forbidsvmplusmock).Plain
cargo build --features vsockstill worked and CI never passesvsock,so nothing was red, but the exact check this PR's acceptance criteria invite
would have failed with a confusing error pointing at
qos_core. Fixed at thesource by building the test's
ManifestEnvelopefrom explicit field literalsso the dev-dependency could be deleted.
The
http_serverintegration test covers: health 200; a v1 parse whosesignature.publicKeymatches the generated ephemeral key;bootProofwithexactly the six production keys; v2 identical to v1; a malformed body
returning 400; an unmatched route returning 404; a disallowed method returning
405; and a 65 KiB body returning 413. All four error paths assert the six
bootProofkeys.It fails fast if the server dies before binding, polling
try_waitalongsidethe port.
wait_until_port_is_boundloops forever, so without that check apivot built for the enclave (which looks for the absolute in-enclave key path
and exits at startup) would hang CI instead of failing it. The client now also
carries a request timeout, so a server that accepts a connection and never
answers fails rather than hanging. Its generated key workdir is gitignored, so
a P256 private key cannot linger on a shared runner.
Rollback
Revert the commits. This adds a new binary that nothing calls yet and changes no existing behavior, so a revert is inert. Nothing is deployed by merging it.
Linear
PRS-581
Stacked on #446 (merged). Supersedes the pivot portion of #304.
🤖 Generated with Claude Code