Skip to content

fix(sdk-typescript): send a literal HTTP body on authenticated requests (PREDICT-9072) - #69

Merged
karanach319 merged 4 commits into
mainfrom
predict-9072-sdk-httptransport-never-sends-a-literal-http-body-on
Sep 23, 2026
Merged

karanach319 merged 4 commits into
mainfrom
predict-9072-sdk-httptransport-never-sends-a-literal-http-body-on

Conversation

@karanach319

Copy link
Copy Markdown
Collaborator

Summary

HttpTransport signs every authenticated request's payload into the X-GEMINI-PAYLOAD header, 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 createCombo in PREDICT-8819 (routing the gemini_create_prediction_combo MCP 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:

<tool-output server="gemini-mcp">
Error: HTTP 400
</tool-output>

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_combos response. 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:

  1. The error message itself was uninformative. HTTP 400 alone gave no indication of why. mcp-server's wrapHandler was only reading err.message, but @gemini-markets/sdk's ApiError.message is always the literal string HTTP {status} by design — the real detail lives on separate .reason/.code/.category properties that were never being surfaced. Fixing that (already merged into PREDICT-8819's branch, ported forward here conceptually) revealed reason=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.

  2. Business-rule rejection was the first suspect, and got ruled out. A category=validation response 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 live listCombos response. That ruled out "this combo isn't allowed."

  3. 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's client.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.

  4. Isolating exactly what differed. A third test hand-built a request using the SDK's own HmacAuth for signing, but with the JSON payload's key order forced to match the legacy client's exact ordering, and no extra Accept header — 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."

  5. Root cause, confirmed by reading code. Tracing HttpTransport.requestWithResponse → send() → the single fetchImpl(...) call site showed it never includes a body field, for any request, ever. The legacy client's own source has a comment explaining exactly why this matters: "POST endpoints need fullBody as an actual JSON request body, not just signed into X-GEMINI-PAYLOAD: some newer prediction-market handlers (e.g. combos) do a real json.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's build() 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 into X-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 the request/nonce wrapper that only the signed header needs. Using the existing stringifyJson (the same bigint-safe serializer already used for the header) rather than plain JSON.stringify means an operation with a bigint field in its body (there are some, elsewhere in the SDK) still serializes losslessly instead of throwing.
  • Headers became conditional instead of hardcoded. Previously Content-Length: "0" and Content-Type: "text/plain" were sent unconditionally. Now: when there's a real body, Content-Type becomes application/json and Content-Length is omitted entirely (left for fetch to compute from the actual body — mirroring exactly what the legacy client does and why, per its own comment: an explicit mismatched Content-Length doesn't error, it just silently causes the body to never reach the server). When there's no body (query-only operations like getPositions), 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 one fetchImpl(...) call site. body: undefined is safe to pass unconditionally — fetch treats 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.
  • Gating is on whether the operation has a body at all (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 (FetchLike already declared body?: string in its init parameter; it was simply never populated), no generated-operation-metadata changes, no consumer-facing API changes.

Testing

  • npm run build && npm run typecheck && npm test in packages/sdk-typescript — 640/640 passing.
  • One pre-existing test had directly asserted the bug as intended behavior ("private request shapes the Gemini payload envelope", literally asserting init.body === undefined with a comment claiming "private REST parameters belong only in the signed payload") — corrected to assert the real body content instead.
  • A shared assertion helper (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.
  • Added 3 new tests: a no-body regression guard (query-only authenticated requests still send nothing), a bigint-in-body precision test, and a dedicated createCombo integration test asserting the real request body.
  • npm run verify:package and npm run verify:runtimes both pass — confirms no change to the package's public API surface or cross-runtime behavior.
  • Live production verification, the real proof: packed this fix into a local tarball, installed it into mcp-server's node_modules (without touching its package.json/lockfile), and re-ran the exact createCombo call 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: reverted node_modules back to the published 0.1.0 SDK, 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.

…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).
@karanach319
karanach319 requested review from a team, fuller and ximt as code owners September 21, 2026 21:05
@linear-code

linear-code Bot commented Sep 21, 2026

Copy link
Copy Markdown

PREDICT-9072

@nostradamus-bot

Copy link
Copy Markdown

Nostradamus Risk Rating — High

The change touches packages/sdk-typescript/src/transport/http.ts, the core HMAC signing transport that handles all 52 authenticated operations (trading, funds, withdrawals, account, margin, staking) — Critical impact. The change itself is a backward-compatible bugfix adding a literal HTTP body alongside the signed payload header without altering the signing or auth logic, placing it at Medium likelihood; the NIST SP 800-30r1 matrix (Critical × Medium) yields High.

High/Critical — a full threat model follows.

@nostradamus-bot

nostradamus-bot Bot commented Sep 21, 2026 •

Copy link
Copy Markdown

Nostradamus Threat Model - Passed - APPSEC-1879

@svc-grace svc-grace left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
@sdlc-pr-bot

sdlc-pr-bot Bot commented Sep 22, 2026 •

Copy link
Copy Markdown

Change Control Evidence Check

Authorization — ✅ Pass

Evidence:

  • Linear ticket: PREDICT-9072
  • Change owner: Karan Acharya
  • Type of change: Normal
Testing — ✅ Pass

Evidence:

  • Tests from CI checks: ➖ No tests ran
  • Security scans: ✅ Passed
  • Testing summary: ✅ Found in PR description
    • Summary: TypeScript SDK build, type checking, and test suite executed successfully with 640/640 tests passing. One pre-existing test asserting incorrect behavior was corrected to validate proper body content inclusion.
  • Evidence link: View run
Approval — ✅ Pass

Evidence:

  • Approver: Jimmy Huang
  • Approval source: GitHub PR Review
  • Approval timestamp: 2026-09-23 14:46 UTC
Segregation of Duties — ✅ Pass

Validated:

  • PR author: karanach319
  • Commit author(s): karanach319
  • Linear assignee: Karan Acharya
  • Approver: Jimmy Huang
  • Result: Implementer and approver are different people ✅

Last checked: 2026-09-23 14:52 UTC

↩ Re-run Clarissa if tickets, CI, or approvals have changed.

@svc-grace svc-grace left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 svc-grace left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 ximt left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 ximt left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@karanach319
karanach319 merged commit 08a366a into main Sep 23, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants