Skip to content

feat(security): authenticate the charges websocket + drop the orphaned guest onramp action - #2534

Merged
Hugo0 merged 3 commits into
mainfrom
fix/remove-guest-onramp-action
Jul 28, 2026
Merged

feat(security): authenticate the charges websocket + drop the orphaned guest onramp action#2534
Hugo0 merged 3 commits into
mainfrom
fix/remove-guest-onramp-action

Conversation

@jjramirezn

@jjramirezn jjramirezn commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Summary

Frontend companion to peanut-api-ts #1247, which closes the authorization fallout from API PR #644.

Two things:

  1. Authenticate the charges WebSocket (the part with real blast radius).
  2. Remove the orphaned createOnrampForGuest server action, whose route feat: handle onesignal push notification initialization  #1247 deletes.

⚠️ This PR merges and deploys BEFORE #1247 — see Merge order.

The WebSocket

GET /ws/charges/:username had no authentication of any kind and was keyed on a public username. Anyone could open it and stream a stranger's charge activity, Bridge/Manteca/Sumsub KYC status including rejectLabels, deposits, pending perks and Rain card balance changes.

Payload-stripping wasn't an option: the app genuinely consumes those payloads (useWebSocket passes rejectLabels straight into the Sumsub KYC handlers). So the fix is real auth, and that needs a client change — the API can't do it alone.

PeanutWebSocket now sends {type:'auth', token} as its first frame and the server subscribes to nothing until it verifies.

A message frame rather than a header or query param because:

  • the browser WebSocket API cannot set custom headers, ever;
  • the jwt-token cookie is host-only on peanut.me, so it is never sent to api.peanut.me (and there's no credentials: 'include' anywhere);
  • a token in the URL would land in every access log between us and the client.

It also suits native: getSessionTokenForSocket() reads the token from the Capacitor cookie jar asynchronously, which a post-open frame can wait for but a connect-time URL cannot. That helper is a deliberate single-caller exception to the "JS is not a token custodian on native" rule in auth-token.ts — the WebView's own WebSocket cannot see CapacitorHttp's jar, so nothing else can carry the credential. The token goes straight into a frame and is never stored.

No reconnect loop on auth failure. A rejection arrives as a non-clean close, which would otherwise fall into the existing 5× exponential-backoff loop and hammer the API with a credential already known to be bad. Close codes 4401/4408 and the auth_error frame now short-circuit it.

The public-profile leak

HomeHistory subscribed to its username prop. On a public profile that prop is the profile owner, so a logged-in viewer opened a socket on a stranger's channel and received their KYC and card-balance events. Every other useWebSocket caller already passed the signed-in user's own username; this was the sole exception.

It now always subscribes to the signed-in user's own channel. The visible list is unchanged — it still comes from useTransactionHistory, keyed on the viewed username with filterMutualTxs, which is what makes a profile show transactions mutual to the viewer.

(With the server-side check in #1247, passing someone else's username would simply fail to connect anyway.)

API types

Regenerated from #1247's openapi.json. Beyond the routes that PR deletes, this picks up four the committed spec had drifted behind — /auth/step-up/options, /auth/step-up/verify, /unsubscribe, /users/logout. Types only, no runtime effect.

CI

Green: typecheck, unit, e2e, format, analyze, Deploy-Preview, ci-success.

eslint is red — pre-existing and repo-wide, not from this PR. It fails on main itself and on unrelated open PRs (#2528, #2533). The two files of mine it lists are flagged on lines this branch never touched (eventListeners: Map<…any…>, the on/off casts, emit(…, data: any), useState<Array<any>>, an existing as any).

Risk

npm run typecheck clean; test suite green apart from one pre-existing failure in add-money-states.test.tsx (loaded EVM deposit shows QR code and address), which also fails on the base commit 74ac9b107 — verified in a throwaway worktree, unrelated to this change. CI's unit job passes it, so it looks environment-dependent locally.

Known gap: native builds already in the app stores do not send the auth frame, so once #1247 deploys they lose live charge/KYC updates until users update. #2489 is on a mobile-release train — worth coordinating so the handshake rides it. Web is fully covered by the merge order below.

Merge order

  1. This PR first. Sending an auth frame is harmless against today's API, whose WS handler ignores unknown message types — so the FE is forward-compatible and there is no window where the web app is broken.
  2. Then peanut-api-ts feat: handle onesignal push notification initialization  #1247, which starts requiring the frame.

This inverts what #1247's original description said ("this PR must merge first"), which was written when this branch was a pure dead-code deletion with no runtime coupling.

Summary by CodeRabbit

  • Bug Fixes

    • Improved WebSocket authentication and prevented repeated reconnect attempts after credential rejection.
    • Ensured live activity appears only on the signed-in user’s own history.
    • Added support for authenticated WebSocket connections in native environments.
  • API Updates

    • Added logout, unsubscribe, and step-up authentication endpoints.
    • Expanded card responses to indicate when balances are unavailable.
    • Updated request validation and error responses for improved compatibility.

createOnrampForGuest called POST /bridge/onramp/create-for-guest, the
unauthenticated route removed in peanut-api-ts #1247. Nothing in the UI
ever called it — only its own two tests did — so this is dead code either
way, but peanut-ui is a public repo and leaving it here keeps advertising
the calling pattern it exploited: userId in the body, no auth header.

Deleting the function orphans CreateOnrampGuestParams and three imports
(CountryData, getCurrencyConfig, getCurrencyPrice), all of which existed
solely to serve it. cancelOnramp and its serverFetch import are untouched.

Generated API types are deliberately left alone: sync-openapi.yml pulls
from staging on a weekday cron and owns them, and staging will not carry
the route removal until the main->dev back-merge lands.

Reported by Gibran, 27 Jul 2026.
@vercel

vercel Bot commented Jul 27, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
peanut-wallet Ready Ready Preview, Comment Jul 27, 2026 10:10pm

Request Review

@coderabbitai

coderabbitai Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 8582d60c-c3dc-4c50-ba4d-9a47191dcb5c

📥 Commits

Reviewing files that changed from the base of the PR and between c068189 and 61ce168.

📒 Files selected for processing (2)
  • src/components/Home/HomeHistory.tsx
  • src/services/websocket.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/components/Home/HomeHistory.tsx
  • src/services/websocket.ts

📝 Walkthrough

Walkthrough

Guest onramp actions and demo routing were removed. API tests now cover direct web and native request routing. WebSocket connections authenticate with session tokens and stop reconnecting after credential rejection. The OpenAPI specification adds, removes, and updates endpoint contracts.

Changes

Onramp API and request routing

Layer / File(s) Summary
Remove guest onramp implementation
src/app/actions/onramp.ts, src/utils/demo-api.ts
Guest onramp exports and the matching demo route were removed.
Validate direct web and native routing
src/app/actions/__tests__/api-headers.test.ts, src/app/actions/__tests__/api-headers-extended.test.ts
Tests verify direct API URLs for web and native requests, including native bearer authorization; obsolete guest onramp assertions were removed.

WebSocket authentication

Layer / File(s) Summary
Session token and authentication handshake
src/utils/auth-token.ts, src/services/websocket.ts
Socket connections resolve session credentials, send authentication frames, expose authentication events, and stop reconnecting after credential rejection.
Authenticated history channel
src/components/Home/HomeHistory.tsx
History subscriptions use the signed-in viewer’s username and gate live entries to the viewer’s own history.

OpenAPI contract updates

Layer / File(s) Summary
Endpoint and schema contract changes
src/types/api.openapi.json
User, authentication, bridge, notification, unsubscribe, card, request, and development endpoint definitions are added, removed, or updated.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant HomeHistory
  participant WebSocketClient
  participant SessionToken
  participant WebSocketServer
  HomeHistory->>WebSocketClient: subscribe with authenticated username
  WebSocketClient->>SessionToken: getSessionTokenForSocket()
  SessionToken-->>WebSocketClient: session token or null
  WebSocketClient->>WebSocketServer: auth frame
  WebSocketServer-->>WebSocketClient: auth_ok or auth_error
  WebSocketClient-->>HomeHistory: authentication event or rejected connection
Loading

Possibly related PRs

Suggested reviewers: kushagrasarathe

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the two main changes: websocket authentication and removal of the orphaned guest onramp action.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/remove-guest-onramp-action

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

ESLint install failed: dependency version conflict. Check your lock file or package.json.


Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Code-analysis diff

Painscore total: 6279.56 → 6278.78 (-0.78)
Findings: -4 net (+371 new, -375 resolved)

🆕 New findings (371)

  • critical complexity — src/utils/demo-api.ts — CC 106, MI 59.5, SLOC 908
  • critical complexity — src/components/Home/HomeHistory.tsx — CC 99, MI 58.51, SLOC 300
  • high structural-dup — types/api.generated.ts:346 — 83 duplicate lines / 384 tokens with types/api.generated.ts:1059
  • high structural-dup — types/api.generated.ts:356 — 74 duplicate lines / 344 tokens with types/api.generated.ts:1069
  • high structural-dup — types/api.generated.ts:139 — 72 duplicate lines / 269 tokens with types/api.generated.ts:9519
  • high structural-dup — types/api.generated.ts:139 — 70 duplicate lines / 264 tokens with types/api.generated.ts:9605
  • high structural-dup — types/api.generated.ts:139 — 67 duplicate lines / 249 tokens with types/api.generated.ts:9196
  • high structural-dup — types/api.generated.ts:9141 — 55 duplicate lines / 162 tokens with types/api.generated.ts:9293
  • high structural-dup — types/api.generated.ts:9141 — 55 duplicate lines / 162 tokens with types/api.generated.ts:9464
  • high structural-dup — types/api.generated.ts:9141 — 55 duplicate lines / 165 tokens with types/api.generated.ts:9722
  • high hotspot — src/components/Home/HomeHistory.tsx — 31 commits, +317/-219 lines since 6 months ago
  • medium high-mdd — src/components/Home/HomeHistory.tsx:49 — HomeHistory: MDD 78.9 (uses across many lines from declarations)
  • medium structural-dup — types/api.generated.ts:3207 — 49 duplicate lines / 139 tokens with types/api.generated.ts:6586
  • medium structural-dup — types/api.generated.ts:3207 — 49 duplicate lines / 137 tokens with types/api.generated.ts:7161
  • medium high-mdd — src/services/websocket.ts:183 — handleMessage: MDD 41.8 (uses across many lines from declarations)
  • medium structural-dup — types/api.generated.ts:667 — 42 duplicate lines / 152 tokens with types/api.generated.ts:2243
  • medium structural-dup — types/api.generated.ts:7238 — 40 duplicate lines / 108 tokens with types/api.generated.ts:7310
  • medium structural-dup — types/api.generated.ts:9156 — 40 duplicate lines / 120 tokens with types/api.generated.ts:9393
  • medium structural-dup — types/api.generated.ts:856 — 39 duplicate lines / 137 tokens with types/api.generated.ts:2629
  • medium structural-dup — types/api.generated.ts:7238 — 39 duplicate lines / 109 tokens with types/api.generated.ts:7384

…and 351 more.

✅ Resolved (375)

  • src/utils/demo-api.ts — CC 107, MI 59.55, SLOC 914
  • src/components/Home/HomeHistory.tsx — CC 97, MI 58.3, SLOC 296
  • types/api.generated.ts:421 — 83 duplicate lines / 384 tokens with types/api.generated.ts:1220
  • types/api.generated.ts:431 — 74 duplicate lines / 344 tokens with types/api.generated.ts:1230
  • types/api.generated.ts:214 — 72 duplicate lines / 269 tokens with types/api.generated.ts:9646
  • types/api.generated.ts:214 — 70 duplicate lines / 264 tokens with types/api.generated.ts:9732
  • types/api.generated.ts:214 — 67 duplicate lines / 249 tokens with types/api.generated.ts:9323
  • types/api.generated.ts:9268 — 55 duplicate lines / 162 tokens with types/api.generated.ts:9420
  • types/api.generated.ts:9268 — 55 duplicate lines / 162 tokens with types/api.generated.ts:9591
  • types/api.generated.ts:9268 — 55 duplicate lines / 165 tokens with types/api.generated.ts:9849
  • src/components/Home/HomeHistory.tsx:49 — HomeHistory: MDD 76.1 (uses across many lines from declarations)
  • types/api.generated.ts:3467 — 49 duplicate lines / 139 tokens with types/api.generated.ts:6713
  • types/api.generated.ts:3467 — 49 duplicate lines / 137 tokens with types/api.generated.ts:7288
  • types/api.generated.ts:7365 — 40 duplicate lines / 108 tokens with types/api.generated.ts:7437
  • types/api.generated.ts:9283 — 40 duplicate lines / 120 tokens with types/api.generated.ts:9520
  • types/api.generated.ts:748 — 39 duplicate lines / 152 tokens with types/api.generated.ts:2287
  • types/api.generated.ts:1017 — 39 duplicate lines / 137 tokens with types/api.generated.ts:2718
  • types/api.generated.ts:7365 — 39 duplicate lines / 109 tokens with types/api.generated.ts:7511
  • types/api.generated.ts:8910 — 39 duplicate lines / 116 tokens with types/api.generated.ts:9060
  • types/api.generated.ts:9947 — 39 duplicate lines / 114 tokens with types/api.generated.ts:10032

…and 355 more.

📈 Painscore deltas (top movers)

File Before After Δ
src/app/actions/onramp.ts 8.4 6.3 -2.1

@jjramirezn

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@github-actions

github-actions Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

🧪 UI test report — ✅ all green

Suites

  • unit: 2180 ran, 0 failed, 0 skipped, 36.9s

📊 Coverage (unit)

metric %
statements 61.1%
branches 44.7%
functions 50.5%
lines 61.5%
⏱ 10 slowest test cases
time test
3.8s src/components/Card/share-asset/__tests__/shareAssetLayout.test.ts › never places two stickers in heavy overlap (broad seed sweep)
1.2s src/utils/__tests__/demo-api.test.ts › isDemoMode() is false when not running under Capacitor
0.5s src/components/Card/share-asset/__tests__/shareAssetLayout.test.ts › every sticker stays within canvas at any count
0.4s src/app/actions/__tests__/api-headers.test.ts › should include Content-Type in validateInviteCode
0.4s src/app/actions/__tests__/api-headers-extended.test.ts › should not include apiKey in validateInviteCode body
0.3s src/app/(mobile-ui)/withdraw/__tests__/withdraw-states.test.tsx › Bank withdrawal keeps the $1 minimum for sub-$1 amounts
0.2s src/utils/__tests__/url.utils.test.ts › uses the public BASE_URL in Capacitor, not the localhost WebView origin
0.2s src/components/Card/share-asset/__tests__/shareAssetLayout.test.ts › keeps stickers off the username pill (final pass respects the keep-out)
0.2s src/utils/__tests__/demo-balance.test.ts › starts at the full balance on a fresh install and stamps a timestamp
0.2s src/utils/__tests__/demo-balance.test.ts › debits and floors at zero
📍 Inline annotations are in the **Unit test report** check above. Coverage artifact: `coverage-unit`. Generated by `.github/workflows/tests.yml`.

@coderabbitai

coderabbitai Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@jjramirezn
jjramirezn marked this pull request as ready for review July 27, 2026 18:18
Companion to peanut-api-ts #1247, which closes the PR #644 authorization
fallout. The API now refuses to stream anything on /ws/charges/:username
until the client proves who it is.

The socket carries charge activity, Bridge/Manteca/Sumsub KYC status -
including rejectLabels - and Rain card balance changes, and it was keyed
on a public username with no credential at all. The frontend consumes
those payloads (useWebSocket passes rejectLabels straight to the KYC
handlers), so stripping them was not an option; the fix has to be auth.

PeanutWebSocket now sends {type:'auth', token} as its first frame and the
server subscribes to nothing until it verifies. A message frame rather
than a header or query param because the browser WebSocket API cannot set
headers, the jwt-token cookie is host-only on peanut.me and so never
reaches api.peanut.me, and a token in a URL would land in every access log
in between. It also suits native: getSessionTokenForSocket reads the token
from the Capacitor cookie jar asynchronously, which a post-open frame can
wait for. That helper is a deliberate, single-caller exception to "JS is
not a token custodian on native" - the WebView's own WebSocket cannot see
CapacitorHttp's jar, so nothing else can carry the credential.

An auth rejection arrives as a non-clean close, which would otherwise fall
into the 5x exponential-backoff loop and hammer the API with a credential
already known to be bad. Close codes 4401/4408 and the auth_error frame
now short-circuit that.

HomeHistory subscribed to the `username` PROP, which on a public profile
is the profile owner - so a logged-in viewer opened a socket on a
stranger's channel and received their KYC and card events. It now always
subscribes to the signed-in user's own channel, matching every other
useWebSocket caller. The visible list is unchanged: it still comes from
useTransactionHistory keyed on the viewed username with filterMutualTxs,
which is what makes a profile show transactions mutual to the viewer.

API types regenerated from the #1247 branch's openapi.json. Beyond the
routes that PR deletes, this picks up four the committed spec had drifted
behind (auth/step-up/*, unsubscribe, users/logout) - types only.
@jjramirezn jjramirezn changed the title fix(security): remove orphaned guest onramp server action feat(security): authenticate the charges websocket + drop the orphaned guest onramp action Jul 27, 2026

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 2

🧹 Nitpick comments (2)
src/services/websocket.ts (1)

191-197: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Inconsistent auth_error payload shape across emission sites.

auth_error is emitted with three different shapes: { reason: 'no-session' } (handleOpen, line 165), message.data ?? null (here, line 196 — server-defined shape), and { code, reason } (handleClose, line 278). A consumer reading .reason vs .code off this single event name will get undefined depending on which path fired it.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/services/websocket.ts` around lines 191 - 197, Standardize the payload
emitted by the auth_error event across handleOpen, the auth_error case in the
message handler, and handleClose. Define and reuse one consistent payload shape
containing the available code and reason fields, mapping server data into that
shape and preserving null or missing values where unavailable.
src/utils/auth-token.ts (1)

53-63: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Dedupe with hasNativeSessionCookie.

This duplicates the exact CapacitorCookies.getCookies + try/catch logic already in hasNativeSessionCookie (lines 70-79). Extract a shared helper so the cookie-jar read logic exists once.

♻️ Suggested refactor

Within the reviewed range:

+async function getNativeSessionCookie(): Promise<string | null> {
+    try {
+        const { CapacitorCookies } = await import('`@capacitor/core`')
+        const cookies = await CapacitorCookies.getCookies({ url: PEANUT_API_URL })
+        return cookies?.[JWT_COOKIE_KEY] ?? null
+    } catch {
+        return null
+    }
+}
+
 export async function getSessionTokenForSocket(): Promise<string | null> {
     if (!isCapacitor()) return getAuthToken()
-    try {
-        const { CapacitorCookies } = await import('`@capacitor/core`')
-        const cookies = await CapacitorCookies.getCookies({ url: PEANUT_API_URL })
-        return cookies?.[JWT_COOKIE_KEY] ?? null
-    } catch {
-        return null
-    }
+    return getNativeSessionCookie()
 }

And update hasNativeSessionCookie (outside this range) to reuse it:

export async function hasNativeSessionCookie(): Promise<boolean> {
    if (!isCapacitor()) return false
    return !!(await getNativeSessionCookie())
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/utils/auth-token.ts` around lines 53 - 63, Extract the shared Capacitor
cookie lookup and error handling from getSessionTokenForSocket into a helper
such as getNativeSessionCookie, returning the JWT cookie value or null. Update
getSessionTokenForSocket and hasNativeSessionCookie to reuse this helper,
preserving their existing non-Capacitor behavior and boolean result.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/components/Home/HomeHistory.tsx`:
- Around line 88-101: Update the history merge logic in HomeHistory so
wsHistoryEntries are included only when isViewingOwnHistory is true. Preserve
the existing useTransactionHistory results and filtering for public profiles,
preventing the viewer’s live websocket transactions from appearing in another
user’s history.

In `@src/services/websocket.ts`:
- Around line 146-173: Ensure the no-session branch in handleOpen emits a
matching disconnect event after auth failure, since disconnect() suppresses
handleClose by clearing the onclose handler. Preserve the existing auth_error
and no-reconnect behavior, and avoid emitting duplicate disconnect events when
normal close handling already runs.

---

Nitpick comments:
In `@src/services/websocket.ts`:
- Around line 191-197: Standardize the payload emitted by the auth_error event
across handleOpen, the auth_error case in the message handler, and handleClose.
Define and reuse one consistent payload shape containing the available code and
reason fields, mapping server data into that shape and preserving null or
missing values where unavailable.

In `@src/utils/auth-token.ts`:
- Around line 53-63: Extract the shared Capacitor cookie lookup and error
handling from getSessionTokenForSocket into a helper such as
getNativeSessionCookie, returning the JWT cookie value or null. Update
getSessionTokenForSocket and hasNativeSessionCookie to reuse this helper,
preserving their existing non-Capacitor behavior and boolean result.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: fe99c5f3-9c1d-4fde-ae60-9d3f6b88849a

📥 Commits

Reviewing files that changed from the base of the PR and between 253f3e5 and c068189.

⛔ Files ignored due to path filters (1)
  • src/types/api.generated.ts is excluded by !**/*.generated.*
📒 Files selected for processing (4)
  • src/components/Home/HomeHistory.tsx
  • src/services/websocket.ts
  • src/types/api.openapi.json
  • src/utils/auth-token.ts

Comment thread src/components/Home/HomeHistory.tsx
Comment thread src/services/websocket.ts
…onnect on the no-session path

Two real bugs from the previous commit, both caught by CodeRabbit.

Switching the socket to the signed-in user's own channel stopped the
cross-user leak, but the merge downstream was left untouched - so on
someone else's profile the VIEWER's own live transactions were still
folded into the list being rendered, which is the profile owner's
history. Derive liveEntries, empty unless viewing your own history, and
merge that instead.

The no-session branch emitted 'connect' (the socket did open), then
'auth_error', then called disconnect(). But disconnect() nulls onclose
before closing, so handleClose - the only other place that emits
'disconnect' - never ran. useWebSocket maps those two events straight
onto its status, so a client with no session sat there reporting
"connected" forever with no reconnect scheduled to correct it. Emit
'disconnect' explicitly on that path.

@Hugo0 Hugo0 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.

Approving. Reviewed the diff rather than the description — the reasoning holds up.

The liveEntries memo is the catch I'd have most expected to be missed: once the socket is always your own channel, merging its entries into a profile you're viewing would inject your transactions into someone else's history. Good spot.

Verified independently:

  • useWebSocket already no-ops on an undefined username (getWebSocketInstance returns null), so the user?.user.username ?? undefined change is safe for logged-out viewers on a public profile.
  • The handleOpendisconnect() → manual emit('disconnect') path is correct — disconnect() nulls onclose first, so without it consumers would stay stuck reporting "connected".

Merge this one first, then peanut-api-ts#1247 — as your description says. Sending an auth frame is inert against today's API.

Two things carried forward, neither blocking:

  1. Old app-store builds don't send the auth frame and don't have the 4401/4408 short-circuit, so they'll fall into the 5× backoff loop — ~6 connections per session before giving up. Noise rather than danger, but it argues for landing the handshake on the mobile-release train with #2489.
  2. The socket isn't re-authenticated after the handshake, so a live connection survives logout. tokenVersionValid is the revocation gate and it only runs once. Cheap follow-up: re-check on the existing ping interval.

@Hugo0
Hugo0 merged commit 258be59 into main Jul 28, 2026
23 of 25 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.

2 participants