fix(sdk-typescript): send a literal HTTP body on authenticated requests (PREDICT-9072) - #69
Conversation
…ts (PREDICT-9072) HttpTransport signed every authenticated request's payload into the X-GEMINI-PAYLOAD header but never attached it as an actual HTTP request body - fetchImpl was always called with no body field. Endpoints whose server handler reads the real HTTP body directly (confirmed: createCombo) rejected the empty body with 400 InvalidInput, while endpoints that read only from the header worked fine. This affects every authenticated + requestBody:true operation across every domain (52 total), not just combos. Fix: requestWithResponse now also serializes the operation's own fields (never the signed envelope's request/nonce) as a literal body whenever the operation has one, with Content-Type/Content-Length adjusted accordingly. Query-only authenticated calls and all public calls are unaffected - body stays undefined exactly as before. Updated the one existing test that asserted the bug as spec, relaxed a shared test helper that hardcoded the no-body header shape across five other domain test suites, and added new coverage: a no-body regression guard, a bigint-in-body precision test, and a dedicated createCombo integration test. Verified live against production: with this fix packed into a local tarball and installed into mcp-server, the SDK's createCombo call that previously failed with 400 InvalidInput now succeeds identically to the legacy client, with the existing mcp-server test suite unaffected. Version bumped 0.1.0 -> 0.1.1 (backward-compatible bugfix).
Nostradamus Risk Rating — HighThe change touches High/Critical — a full threat model follows. |
|
Nostradamus Threat Model - Passed - APPSEC-1879 |
svc-grace
left a comment
There was a problem hiding this comment.
Agentic Review
Nice, this fixes the core transport bug cleanly, and the build, typecheck, full SDK suite, package checks, and live combo verification all line up. One follow-up remains around the generated-surface tests: they still allow the old missing-body behavior, so tightening those assertions would help keep this regression from coming back. Nothing here blocks the PR.
Review process
Grace version: v0.0.210
Files reviewed (6): http.test.ts, http.ts, package-lock.json, package.json, prediction-markets.test.ts, rest-surfaces.test.ts
Guidelines: none discovered
Verification: 1 of 3 findings verified
Findings: 0 critical, 0 important, 1 suggestion
Linked tickets: PREDICT-9072
LLM usage: 116 calls — gpt-5.6-luna: 89 calls, 6849266 tokens, us.anthropic.claude-opus-4-6-v1: 26 calls, 824280 tokens, us.anthropic.claude-sonnet-4-6: 1 call, 208806 tokens
npm audit --audit-level=high found 4 pre-existing high-severity vulnerabilities in devDependencies, unrelated to the HttpTransport fix but blocking the validate check on this PR (and would have blocked the real publish pipeline too, since publish-typescript-sdk.yml runs the identical audit command before publishing). - js-yaml (via @redocly/openapi-core): resolved transitively by npm audit fix, no direct dependency change needed. - sharp (via miniflare): required npm audit fix --force, bumping miniflare to a newer alpha (5.20260825.0-alpha -> 5.20260921.0-alpha). Verified this is a real breaking change, not just a version bump: the new major version removed the `type` field from its worker config schema (confirmed against its own generated type definitions), which broke verify:runtimes' Cloudflare Workers check. Fixed by removing that one now-rejected field from scripts/verify-multi-runtime.mjs's Miniflare config. Confirmed via --omit=dev that none of these ever affected the published package - devDependencies only, zero risk to consumers. Verified: npm audit --audit-level=high and --omit=dev both report 0 vulnerabilities, full build/typecheck/test (640/640) passes, and verify:package/verify:runtimes both pass with the new miniflare version.
Change Control Evidence CheckAuthorization — ✅ PassEvidence:
Testing — ✅ PassEvidence:
Approval — ✅ PassEvidence:
Segregation of Duties — ✅ PassValidated:
Last checked: 2026-09-23 14:52 UTC ↩ Re-run Clarissa if tickets, CI, or approvals have changed. |
svc-grace
left a comment
There was a problem hiding this comment.
Agentic Review
The authenticated transport now sends the operation payload as a real JSON body while keeping the signed envelope separate, and preserves the no-body behavior for query-only requests. Nice coverage across serialization, header behavior, multiple domains, and the live combo flow. The remaining follow-up is that generated surface tests still accept the pre-fix missing body; that’s non-blocking polish, but updating those expectations would keep the tests from encoding the old bug.
Persisted from prior passes (1 open)
Review process
Grace version: v0.0.210
Files reviewed (3): package-lock.json, package.json, verify-multi-runtime.mjs
Files skipped: 7
Guidelines: none discovered
Findings: 0 critical, 0 important, 0 suggestions
Linked tickets: PREDICT-9072, APPSEC-1879
LLM usage: 20 calls — gpt-5.6-luna: 20 calls, 846939 tokens
…-9072) Grace's review flagged (across two passes) that assertSigned accepts either the body-present or no-body shape rather than asserting one specific expectation per request, so a regression wouldn't be caught by these particular tests. Investigated tightening it by deriving the expectation from the signed payload's own keys (extra fields beyond request/nonce implies a body). That broke on real cases: some requestBody:true operations (getRoles, oauth revoke) take zero fields, so their signed payload is indistinguishable from a query-only operation's by content alone. Resolving that correctly needs each operation's own requestBody metadata cross-referenced per call site across 30+ calls in this file - real scope, not a quick tightening. Left the lenient check as-is and documented why, so this doesn't get re-flagged as an oversight. The actual regression protection for this bug already lives in transport/http.ts's own test file: a dedicated no-body guard and a createCombo integration test, both asserting one specific expected shape per case.
svc-grace
left a comment
There was a problem hiding this comment.
Agentic Review
The authenticated transport now sends the operation fields as a real JSON body while preserving the signed envelope, and the no-body path remains unchanged. The main fix looks well covered, including bigint serialization and cross-domain behavior. One non-blocking follow-up remains: the generated surface tests still accept the pre-fix missing body, so they should be updated to assert the corrected behavior; this does not block the PR.
Persisted from prior passes (1 open)
Review process
Grace version: v0.0.210
Files reviewed (1): rest-surfaces.test.ts
Files skipped: 12
Suggestions damped: 1 (pass 3 with nothing blocking — new suggestion-level findings are withheld so the review converges, TOOLS-6878)
Guidelines: none discovered
Verification: 0 of 1 findings verified
Findings: 0 critical, 0 important, 0 suggestions
Linked tickets: PREDICT-9072, APPSEC-1879
LLM usage: 17 calls — gpt-5.6-luna: 17 calls, 964126 tokens
ximt
left a comment
There was a problem hiding this comment.
Found one Important correctness issue in the authenticated transport. The full SDK test suite and typecheck pass, but the generated GET file-report operation is not covered by a native fetch implementation and regresses when the new literal body is attached.
| // exclusively from the signed X-GEMINI-PAYLOAD header. Send the operation's own | ||
| // fields (never the signed envelope's `request`/`nonce`) as a second, literal | ||
| // copy whenever there's an actual body to send, so both endpoint styles work. | ||
| const body = stableParams !== undefined ? stringifyJson(stableParams) : undefined; |
There was a problem hiding this comment.
[ISSUE] Important: GET requests are sent with a body
File: packages/sdk-typescript/src/transport/http.ts
Line: 1054
Search Text: const body = stableParams !== undefined ? stringifyJson(stableParams) : undefined;
The generated perpetuals.getFundingPaymentReportFile operation is a GET with requestBody: true and signQuery: true. executeRestOperation therefore supplies params (at least {} and, for account, actual fields), so this line produces a body and send forwards it to native fetch. Native fetch rejects any GET/HEAD request with a body (TypeError: Request with GET/HEAD method cannot have body), so this existing generated SDK method fails before reaching the API. The same happens for direct GET calls with params.
Recommended: Only attach a literal body for methods that permit one (or keep GET params in the signed payload/query); add a native-fetch regression for getFundingPaymentReportFile.
There was a problem hiding this comment.
Hi Jimmy I have made the fix and its pointing to the new test.
…DICT-9072) - Only send the literal JSON body for non-GET requests; native fetch rejects a body on GET/HEAD. Signed GET params (e.g. perpetuals.getFundingPaymentReportFile) stay in the signed payload only, as before. - Add native-fetch regression tests against a local server: getFundingPaymentReportFile sends no body and succeeds, and a signed POST delivers its JSON body. - Assert the perpetuals file-report GET has no body in the rest-surfaces test.
ximt
left a comment
There was a problem hiding this comment.
Re-reviewed the latest head 53610f3. The previously reported GET-body regression is fixed by excluding GET from literal body attachment and is covered by a native-fetch regression test for getFundingPaymentReportFile. No additional actionable correctness issues found. Verification passed: npm ci with 0 vulnerabilities, 642/642 tests, typecheck, build, Node and Cloudflare runtime checks, and package/API-surface verification.
Summary
HttpTransportsigns every authenticated request's payload into theX-GEMINI-PAYLOADheader, but never sends that payload as an actual literal HTTP request body. Endpoints whose server-side handler reads the real HTTP body directly (confirmed:createCombo) reject the empty body with a 400, while endpoints that read exclusively from the signed header work fine. This is a structural gap in the transport layer affecting every authenticated operation with a real request body — 52 operations across every domain (predictions, trading, account, margin, staking, transfers, clearing, instant, perpetuals) — not just combos.Linear: https://linear.app/gemini/issue/PREDICT-9072/sdk-httptransport-never-sends-a-literal-http-body-on-authenticated
How this was found
This surfaced while migrating
createComboin PREDICT-8819 (routing thegemini_create_prediction_comboMCP tool off the legacy hand-rolled HTTP client and onto this SDK). Every attempt to actually register a combo through the migrated tool failed with:The specific trade being tested was a real 2-leg combo — Green Bay Packers win + LA Rams win — picked directly from a live
gemini_list_prediction_combosresponse. This wasn't a contrived edge case: the same combination is tradeable today on the Gemini desktop app with no restriction. If the desktop app allows it, an agent calling the same underlying API should be able to combo it too — so a 400 here meant something was actually wrong, not that the combo itself was invalid.The investigation went through several stages before finding the real cause:
The error message itself was uninformative.
HTTP 400alone gave no indication of why. mcp-server'swrapHandlerwas only readingerr.message, but@gemini-markets/sdk'sApiError.messageis always the literal stringHTTP {status}by design — the real detail lives on separate.reason/.code/.categoryproperties that were never being surfaced. Fixing that (already merged into PREDICT-8819's branch, ported forward here conceptually) revealedreason=InvalidInput, code=invalid_input, category=validation— still generic, but confirmed the request was reaching the server and being evaluated, not just malformed at the transport level.Business-rule rejection was the first suspect, and got ruled out. A
category=validationresponse looks exactly like Gemini legitimately saying "no" to this specific input. But the Green Bay + LA Rams combo is not blocked anywhere else — it's placeable on the desktop app right now, and it existed as a real, already-registered combo in the livelistCombosresponse. That ruled out "this combo isn't allowed."Direct A/B proof: legacy vs. SDK, identical input. The same exact request — same credentials, same two legs, same signed payload content — was sent through mcp-server's legacy hand-rolled client (
succeeded,alreadyExisted: true) and through the SDK'sclient.predictions.createCombo(...)(failed,InvalidInput). Since permissions are key-level, not client-level, and the same input was accepted from one code path and rejected from the other, this definitively ruled out any account/permission/business-rule explanation. This was a client-side bug.Isolating exactly what differed. A third test hand-built a request using the SDK's own
HmacAuthfor signing, but with the JSON payload's key order forced to match the legacy client's exact ordering, and no extraAcceptheader — to check whether payload field order was the culprit. It still failed, with a more specific raw response body:{"error":"InvalidInput","message":"Invalid request body"}. That ruled out key ordering and pointed straight at "the body is structurally empty."Root cause, confirmed by reading code. Tracing
HttpTransport.requestWithResponse→send()→ the singlefetchImpl(...)call site showed it never includes abodyfield, for any request, ever. The legacy client's own source has a comment explaining exactly why this matters: "POST endpoints needfullBodyas an actual JSON request body, not just signed into X-GEMINI-PAYLOAD: some newer prediction-market handlers (e.g. combos) do a realjson.Decode(r.Body)server-side and reject an empty body with a 400, unlike the legacy private-API endpoints that read exclusively from the header." The SDK never implements that second half.The fix
In
packages/sdk-typescript/src/transport/http.ts:requestWithResponse'sbuild()closure now computes a second, separate serialization of just the operation's own fields —stringifyJson(stableParams)— alongside the existing signed-envelope payload ({request, nonce, ...stableParams}) that goes intoX-GEMINI-PAYLOAD. This is deliberately not a reuse of the signed envelope string: the literal HTTP body must contain only the operation's fields (e.g.{"legs":[...]}), never therequest/noncewrapper that only the signed header needs. Using the existingstringifyJson(the same bigint-safe serializer already used for the header) rather than plainJSON.stringifymeans an operation with abigintfield in its body (there are some, elsewhere in the SDK) still serializes losslessly instead of throwing.Content-Length: "0"andContent-Type: "text/plain"were sent unconditionally. Now: when there's a real body,Content-Typebecomesapplication/jsonandContent-Lengthis omitted entirely (left forfetchto compute from the actual body — mirroring exactly what the legacy client does and why, per its own comment: an explicit mismatchedContent-Lengthdoesn't error, it just silently causes the body to never reach the server). When there's no body (query-only operations likegetPositions), the headers stay exactly as they were — zero behavior change for anything already working in production.send()'s signature changed from a callback returning bare headers to one returning{ headers, body? }, threaded through to the onefetchImpl(...)call site.body: undefinedis safe to pass unconditionally —fetchtreats it as no body.requestPublicWithResponse(the unauthenticated path) was updated to match the new callback shape, with no behavior change — no public operation in the SDK is schema-declared to have a request body, so this path never had anything to send anyway.stableParams !== undefined), not on HTTP method — this naturally covers "only real POST-with-body operations get this treatment" without hardcoding method checks, and leaves every currently-working query-only authenticated call untouched.This is entirely internal to
http.ts— no public type changes (FetchLikealready declaredbody?: stringin itsinitparameter; it was simply never populated), no generated-operation-metadata changes, no consumer-facing API changes.Testing
npm run build && npm run typecheck && npm testinpackages/sdk-typescript— 640/640 passing."private request shapes the Gemini payload envelope", literally assertinginit.body === undefinedwith a comment claiming "private REST parameters belong only in the signed payload") — corrected to assert the real body content instead.assertSigned, used across 5 other domain integration test files — trading, margin, perpetuals, account/staking/transfers, clearing/instant) hardcoded the old no-body header shape for every authenticated request it checked, regardless of operation type. Relaxed it to assert internal consistency (headers match body presence) instead of one universal shape — this is what independently confirmed the fix correctly touches all of those domains, not just predictions.createCombointegration test asserting the real request body.npm run verify:packageandnpm run verify:runtimesboth pass — confirms no change to the package's public API surface or cross-runtime behavior.mcp-server'snode_modules(without touching itspackage.json/lockfile), and re-ran the exactcreateCombocall that had been failing — the same Green Bay Packers + LA Rams combo. The SDK client now succeeds with output identical to the legacy client (alreadyExisted: true, same combo data, all int64 fields correctly converted to strings by mcp-server's existing mapping layer). Also re-ran mcp-server's full test suite (294 tests) with the fixed SDK installed — no regressions. Cleaned up afterward: revertednode_modulesback to the published0.1.0SDK, removed the local tarball.Version bumped
0.1.0→0.1.1(backward-compatible bugfix) so this can actually reach consumers once published — see the PR description's version-bump note for why that's a required part of this change, not an afterthought.