Skip to content

feat(transport): websocket transport support#290

Merged
brendanjryan merged 3 commits intomainfrom
o-az/ws-transport-support
Apr 7, 2026
Merged

feat(transport): websocket transport support#290
brendanjryan merged 3 commits intomainfrom
o-az/ws-transport-support

Conversation

@o-az
Copy link
Copy Markdown
Member

@o-az o-az commented Apr 2, 2026

Adds a working end-to-end websocket transport for tempo.session().

This keeps the initial 402 bootstrap over HTTP, then moves the rest of the session flow onto the websocket: initial auth, mid-stream voucher top-ups, final stop/close, and settlement receipt. The app layer only sees streamed content.

Also fixes a few issues that showed up once the flow was exercised end to end:

  • app payloads no longer share the raw websocket control-frame surface
  • websocket payment control frames are now bound to the active session instead of being trusted opportunistically
  • close() no longer signs from stale / unvalidated websocket close state
  • fallback HTTP close no longer relies on stale spent state after websocket disconnect
  • server-side websocket payment handling now has queue guards against frame flooding
  • added regression coverage around websocket control-frame isolation and session binding

includes a runnable examples/session/ws demo:

CleanShot.2026-04-02.at.15.25.31.mp4
Additional hardening from security review
  • Delivery tracking for fallback close — client counts delivered chunks (wsDeliveredChunks × wsTickCost) to prevent overpaying on unexpected WS disconnect instead of signing for the full cumulative voucher authorization
  • parseMessage structural validation — malformed frames with truthy non-object data (e.g. {"mpp":"payment-receipt","data":42}) are rejected at parse time instead of propagating and killing the socket with a misleading error
  • Compose underbilling preventionWs.serve() accepts an optional amount parameter that validates the echoed challenge price, preventing a client from selecting a cheaper composed offer for the same stream
  • Managed socket message buffering — the managed socket returned by session.ws() buffers messages until the first listener is installed, eliminating the setTimeout(fn, 0) timing dependency
  • maxDeposit enforcement on voucher requests — both SSE and WS paths validate voucher amounts against the local maxDeposit limit
  • Synthetic POST documentation — JSDoc on Ws.serve() and the route option documenting that credentials are verified via synthetic POST with only the Authorization header
  • isows migration — example client uses isows instead of ws for isomorphic WebSocket support

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new bot commented Apr 2, 2026

Open in StackBlitz

npm i https://pkg.pr.new/mppx@290

commit: 9986957

@socket-security
Copy link
Copy Markdown

socket-security bot commented Apr 2, 2026

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Added@​types/​ws@​8.18.11001007480100
Added@​types/​node@​25.5.21001008196100
Updatedws@​8.19.0 ⏵ 8.20.09810010090100

View full report

@tempoxyz-bot
Copy link
Copy Markdown

tempoxyz-bot commented Apr 2, 2026

👁️ Cyclops Security Review

6c122c6

🧭 Auditing · mode=normal · workers 0/3 done (3 left) · verify pending 0

Worker Engine Progress Status
pr-290-w1 gemini-3.1-pro-preview 🔍 thread-1 · · Running
pr-290-w2 amp/deep 🔍 thread-1 · · Running
pr-290-w3 gpt-5.4 🔍 thread-1 · · Running
⚙️ Controls
  • 🚀 Keep only 1 remaining iteration per worker after the current work finishes.
  • 👀 Keep only 2 remaining iterations per worker after the current work finishes.
  • ❤️ Let only worker 1 continue; other workers skip queued iterations.
  • 😄 Let only worker 2 continue; other workers skip queued iterations.
  • 🎉 End faster by skipping queued iterations and moving toward consolidation.
  • 😕 Stop active workers/verifiers now and start consolidation immediately.

📜 3 events

🔍 pr-290-w1 iter 1/3 [audit-ripple.md]
🔍 pr-290-w3 iter 1/3 [audit-deep-focus.md]
🔍 pr-290-w2 iter 1/3 [audit-focused.md]

@brendanjryan
Copy link
Copy Markdown
Collaborator

brendanjryan commented Apr 2, 2026

left some comments on overall design -- especially given sensitivity here for security flows

kicked off a cyclops run too

can you attach a diagram / flow of the state machine? is hard to understand from code alone

@o-az
Copy link
Copy Markdown
Member Author

o-az commented Apr 2, 2026

@brendanjryan
Fair call. This is still pretty procedural. The goal of this PR wasn’t to land the final abstraction so much as to get a working end-to-end websocket shape for tempo.session() that we can actually use and evaluate.

so I'm thinking of it as implementation-first, not protocol-final:

  • HTTP 402 bootstrap
  • in-band websocket auth / voucher top-ups / close
  • final settlement still delegated to the existing session semantics

In other words, it's not “this is now the protocol”. It’s more “this is a concrete state machine that works, and we can use it to decide what the right generalized protocol shape should be.”

Added a verbose diagram below since I agree it’s hard to reason about from code alone.

stateDiagram-v2
  [*] --> Idle

  Idle --> Probe402: client GET /ws/chat (HTTP)
  Probe402 --> WsConnect: 402 + Payment challenge
  Probe402 --> Failed: no challenge / invalid challenge

  WsConnect --> AwaitAuthReceipt: open websocket\n send initial authorization frame
  WsConnect --> Failed: ws open fails

  AwaitAuthReceipt --> Streaming: server verifies open\n sends payment-receipt
  AwaitAuthReceipt --> Failed: payment-error / socket close

  Streaming --> AwaitVoucher: server sends payment-need-voucher
  AwaitVoucher --> Streaming: client sends higher cumulative voucher\nserver verifies + sends payment-receipt

  Streaming --> AwaitCloseReady: client requests close
  AwaitVoucher --> AwaitCloseReady: client requests close while paused for coverage

  AwaitCloseReady --> AwaitCloseReceipt: server stops stream\ncomputes final spent\nsends payment-close-ready\nclient signs final close credential
  AwaitCloseReady --> Failed: payment-error / socket close

  AwaitCloseReceipt --> Settled: server verifies close\nsettles onchain\nsends payment-receipt
  AwaitCloseReceipt --> Failed: payment-error / socket close

  Settled --> Closed: websocket closes
  Closed --> [*]
  Failed --> [*]

Loading

@tempoxyz-bot
Copy link
Copy Markdown

tempoxyz-bot commented Apr 3, 2026

👁️ Cyclops Security Review

6c122c6

🧭 Consolidating · mode=normal · workers 1/3 done (2 left) · verify pending 0

Worker Engine Progress Status
pr-290-w1 gemini-3.1-pro-preview 🚨 thread-1 ⏰ thread-2 🚨 thread-3 Done
pr-290-w2 amp/deep 🔍 thread-1 · · Running
pr-290-w3 gpt-5.4 🔍 thread-1 · · Running

Findings

# Finding Severity Verification Threads
1 Unbounded Promise Queue in WebSocket Handler Leads to DoS via CPU Exhaustion High ✅ Verified audit · verify
2 SDK SessionManager Uses Outdated spent Amount for Close Vouchers, Locking Funds on Disconnect High ✅ Verified audit · verify

🔄 Consolidation running · Thread

⚙️ Controls
  • 🚀 Keep only 1 remaining iteration per worker after the current work finishes.
  • 👀 Keep only 2 remaining iterations per worker after the current work finishes.
  • ❤️ Let only worker 1 continue; other workers skip queued iterations.
  • 😄 Let only worker 2 continue; other workers skip queued iterations.
  • 🎉 End faster by skipping queued iterations and moving toward consolidation.
  • 😕 Stop active workers/verifiers now and start consolidation immediately.

📜 16 events

🔍 pr-290-w1 iter 1/3 [audit-ripple.md]
🔍 pr-290-w3 iter 1/3 [audit-deep-focus.md]
🔍 pr-290-w2 iter 1/3 [audit-focused.md]
🚨 pr-290-w1 iter 1 — finding | Thread
🚨 Finding: Unbounded Promise Queue in WebSocket Handler Leads to DoS via CPU Exhaustion (High) | Thread
🔍 pr-290-w1 iter 2/3 [audit-historical.md]
🔬 Verifying: Unbounded Promise Queue in WebSocket Handler Leads to DoS via CPU Exhaustion | Thread
📋 Verify: Unbounded Promise Queue in WebSocket Handler Leads to DoS via CPU Exhaustion → ✅ Verified | Thread
pr-290-w1 iter 2 — timeout | Thread
🔍 pr-290-w1 iter 3/3 [audit-focused.md]
🚨 pr-290-w1 iter 3 — finding | Thread
🚨 Finding: SDK SessionManager Uses Outdated spent Amount for Close Vouchers, Locking Funds on Disconnect (High) | Thread
🏁 pr-290-w1 done
🔬 Verifying: SDK SessionManager Uses Outdated spent Amount for Close Vouchers, Locking Funds on Disconnect | Thread
📋 Verify: SDK SessionManager Uses Outdated spent Amount for Close Vouchers, Locking Funds on Disconnect → ✅ Verified | Thread
📦 Consolidation: running | Thread

if (type === 'close') {
if (emittedClose) return
emittedClose = true
readyState = 3
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.

what are these readyStates?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

These are the standard websocket readyState ints. I’ll make them explicit so this isn’t relying on magic numbers.

rawSocket.send(Ws.formatAuthorizationMessage(voucher))
break
}
case 'payment-receipt':
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.

this smells a little complex -- is there a more functional / robust way to represent this state machine? in general this seems pretty fragile and I worry that it would be easy to mess up

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fair call. The current shape is definitely explicit/procedural. The goal here was to get a working end-to-end websocket transport for tempo.session(), not to claim we’ve landed the final abstraction. I do think we should make the active socket/session state more explicit though and I’m tightening that in this PR so the current state machine is less fragile.

Copy link
Copy Markdown
Collaborator

@brendanjryan brendanjryan left a comment

Choose a reason for hiding this comment

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

given the isolation with other primitives, I think this is safe to merge now as long as the cyclops check is clean.

I do want to rethink this code to make it less procedural, but we should not block on this

Copy link
Copy Markdown

@tempoxyz-bot tempoxyz-bot left a comment

Choose a reason for hiding this comment

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

👁️ Cyclops Review

This PR adds experimental WebSocket transport for Tempo payment sessions. The implementation correctly serializes concurrent frames and reuses the existing HTTP challenge/verification flow, but introduces 3 high-severity and 1 medium-severity security issue in the new WebSocket transport layer.

# Tier Finding File
1 🚨 SECURITY Application frames parsed as payment control — injection enables fund drain SessionManager.ts:500, Ws.ts:161
2 🚨 SECURITY Client trusts server-supplied channelId without binding — voucher retargeting SessionManager.ts:541
3 🚨 SECURITY Unbounded promise queue — CPU exhaustion DoS Ws.ts:253-262
4 ⚠️ ISSUE HTTP-fallback close uses stale spent — funds locked after WS disconnect SessionManager.ts:600
Reviewer Callouts
  • WebSocket Bootstrap Invariant: Ws.ts:222-239 starts the application stream after any non-close authorization, including topUp, even though topUp is documented as management-only in Session.ts:262-274. This deserves a manual pass.
  • WS Stream Concurrency: The action promise queue correctly serializes concurrent frames for channel.highestVoucherAmount mutations, but the lack of backpressure and closed-state guards exposes the server to queue-flooding.
  • WebSocket State Syncing: The decision not to send payment-receipt during streaming causes the client's spent variable to fall out of sync with the server. Ensure the client can reliably fall back to channel.cumulativeAmount for close transactions.

@o-az o-az force-pushed the o-az/ws-transport-support branch from 53918e4 to cfa18a8 Compare April 3, 2026 20:09
@tempoxyz-bot
Copy link
Copy Markdown

tempoxyz-bot commented Apr 6, 2026

👁️ Cyclops Security Review

2ed2b64

🧭 Auditing · mode=normal · workers 1/3 done (2 left) · verify pending 1

Worker Engine Progress Status
pr-290-w1 gemini-3.1-pro-preview 🚨 thread-1 🚨 thread-2 ✅ thread-3 Done
pr-290-w2 amp/deep 🚨 thread-1 🔍 thread-2 · Running
pr-290-w3 gpt-5.4 🚨 thread-1 🔍 thread-2 · Running

Findings

# Finding Severity Verification Threads
1 WebSocket payee can harvest vouchers before delivering stream data High ✅ Verified audit · verify
2 Malicious Server Can Drain Channel by Poisoning Local spent State via Receipts High ❌ Rejected audit · verify
3 Client Blindly Signs Arbitrary Voucher Amounts Bypassing maxDeposit High ⏩ Dup audit · verify
4 SessionManager.close() signs optimistic cumulative amounts after failed payments High ⏳ Pending audit
⚙️ Controls
  • 🚀 Keep only 1 remaining iteration per worker after the current work finishes.
  • 👀 Keep only 2 remaining iterations per worker after the current work finishes.
  • ❤️ Let only worker 1 continue; other workers skip queued iterations.
  • 😄 Let only worker 2 continue; other workers skip queued iterations.
  • 🎉 End faster by skipping queued iterations and moving toward consolidation.
  • 😕 Stop active workers/verifiers now and start consolidation immediately.

📜 24 events

🔍 pr-290-w1 iter 1/3 [audit-ripple.md]
🔍 pr-290-w2 iter 1/3 [audit-focused.md]
🔍 pr-290-w3 iter 1/3 [audit-deep-focus.md]
🚨 pr-290-w2 iter 1 — finding | Thread
🚨 Finding: WebSocket payee can harvest vouchers before delivering stream data (High) | Thread
🔬 Verifying: WebSocket payee can harvest vouchers before delivering stream data | Thread
🔍 pr-290-w2 iter 2/3 [audit-ripple.md]
🚨 pr-290-w1 iter 1 — finding | Thread
🚨 Finding: Malicious Server Can Drain Channel by Poisoning Local spent State via Receipts (High) | Thread
🔍 pr-290-w1 iter 2/3 [audit-historical.md]
🔬 Verifying: Malicious Server Can Drain Channel by Poisoning Local spent State via Receipts | Thread
📋 Verify: WebSocket payee can harvest vouchers before delivering stream data → ✅ Verified | Thread
🚨 pr-290-w1 iter 2 — finding | Thread
🚨 Finding: Client Blindly Signs Arbitrary Voucher Amounts Bypassing maxDeposit (High) | Thread
🔍 pr-290-w1 iter 3/3 [audit-focused.md]
🔬 Verifying: Client Blindly Signs Arbitrary Voucher Amounts Bypassing maxDeposit | Thread
📋 Verify: Malicious Server Can Drain Channel by Poisoning Local spent State via Receipts → ❌ Rejected | Thread
🚨 pr-290-w3 iter 1 — finding | Thread
🚨 Finding: SessionManager.close() signs optimistic cumulative amounts after failed payments (High) | Thread
🔍 pr-290-w3 iter 2/3 [audit-focused.md]
🔬 Verifying: SessionManager.close() signs optimistic cumulative amounts after failed payments | Thread
📋 Verify: Client Blindly Signs Arbitrary Voucher Amounts Bypassing maxDeposit → ⏩ Dup | Thread
pr-290-w1 iter 3 — clear | Thread
🏁 pr-290-w1 done

@o-az o-az force-pushed the o-az/ws-transport-support branch from 67aa317 to 6da52c6 Compare April 6, 2026 21:07
Addresses findings from Cyclops bot review, Amp security audit, and
manual analysis:

- bind close receipts to signed close amount (prevents open receipt replay)
- commit spend only when streaming chunks are delivered (prevents overcharging)
- enforce local maxDeposit on streamed voucher requests
- track delivered chunks for fallback close (prevents overpayment on disconnect)
- validate parseMessage data fields structurally instead of truthy checks
- add amount validation to Ws.serve to prevent compose underbilling
- buffer managed socket messages until first listener is installed
- document synthetic POST behavior on Ws.serve route
- use app-defined WS close code for browser compatibility
- use isows for example client WebSocket
@o-az o-az force-pushed the o-az/ws-transport-support branch from 0dc91e1 to 121425d Compare April 6, 2026 22:19
The Cyclops fix changed the non-WS fallback close from spent.toString()
to max(cumulative, spent), which signs for the full voucher authorization
instead of actual consumption. SSE keeps spent in sync via inline
receipts, so the original spent.toString() is correct.
Copy link
Copy Markdown
Collaborator

@brendanjryan brendanjryan left a comment

Choose a reason for hiding this comment

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

LGTM -- much cleaner. Thank you!!

"editor.formatOnSave": true
"editor.formatOnSave": true,
"typescript.enablePromptUseWorkspaceTsdk": true,
"typescript.tsdk": "node_modules/typescript/lib"
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.

👍

}

const WebSocketReadyState = {
CONNECTING: 0,
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.

👍

@brendanjryan brendanjryan merged commit 5a4ee68 into main Apr 7, 2026
8 checks passed
@brendanjryan brendanjryan deleted the o-az/ws-transport-support branch April 7, 2026 00:58
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