From 253f3e57ff48386ef7a844df8280e69981746a11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Ram=C3=ADrez?= Date: Mon, 27 Jul 2026 15:10:34 -0300 Subject: [PATCH 1/3] fix(security): remove orphaned guest onramp server action MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../__tests__/api-headers-extended.test.ts | 13 ----- src/app/actions/__tests__/api-headers.test.ts | 12 ----- src/app/actions/onramp.ts | 48 ------------------- src/utils/demo-api.ts | 1 - 4 files changed, 74 deletions(-) diff --git a/src/app/actions/__tests__/api-headers-extended.test.ts b/src/app/actions/__tests__/api-headers-extended.test.ts index d61da6056a..48814b06e7 100644 --- a/src/app/actions/__tests__/api-headers-extended.test.ts +++ b/src/app/actions/__tests__/api-headers-extended.test.ts @@ -175,17 +175,4 @@ describe('action functions should NOT include apiKey in body', () => { expect(body).not.toBeNull() expect(body).not.toHaveProperty('apiKey') }) - - it('should not include apiKey in createOnrampForGuest body', async () => { - const { createOnrampForGuest } = require('@/app/actions/onramp') - await createOnrampForGuest({ - amount: '100', - country: { id: 'US', name: 'United States', code: 'US' }, - userId: 'user-123', - }) - - const body = getLastCallBody() - expect(body).not.toBeNull() - expect(body).not.toHaveProperty('apiKey') - }) }) diff --git a/src/app/actions/__tests__/api-headers.test.ts b/src/app/actions/__tests__/api-headers.test.ts index a764dd76b5..827402f3d7 100644 --- a/src/app/actions/__tests__/api-headers.test.ts +++ b/src/app/actions/__tests__/api-headers.test.ts @@ -169,18 +169,6 @@ describe('action functions Content-Type headers', () => { const headers = getLastCallHeaders() expect(headers['Content-Type']).toBe('application/json') }) - - it('should include Content-Type in createOnrampForGuest', async () => { - const { createOnrampForGuest } = require('@/app/actions/onramp') - await createOnrampForGuest({ - amount: '100', - country: { id: 'US', name: 'United States', code: 'US' }, - userId: 'user-123', - }) - - const headers = getLastCallHeaders() - expect(headers['Content-Type']).toBe('application/json') - }) }) // Post-proxy-removal: web calls PEANUT_API_URL directly (same as native). diff --git a/src/app/actions/onramp.ts b/src/app/actions/onramp.ts index 804272a300..dd1d260f99 100644 --- a/src/app/actions/onramp.ts +++ b/src/app/actions/onramp.ts @@ -1,15 +1,5 @@ -import { type CountryData } from '@/components/AddMoney/consts' -import { getCurrencyConfig } from '@/utils/bridge.utils' -import { getCurrencyPrice } from '@/app/actions/currency' import { serverFetch } from '@/utils/api-fetch' -export interface CreateOnrampGuestParams { - amount: string - country: CountryData - userId: string - chargeId?: string -} - /** * Cancel an on-ramp transfer. * @@ -39,41 +29,3 @@ export async function cancelOnramp(transferId: string): Promise<{ data?: { succe return { error: 'An unexpected error occurred.' } } } - -export async function createOnrampForGuest( - params: CreateOnrampGuestParams -): Promise<{ data?: { success: boolean }; error?: string }> { - try { - const { currency, paymentRail } = getCurrencyConfig(params.country.id, 'onramp') - const price = await getCurrencyPrice(currency) - const amount = (Number(params.amount) * price.buy).toFixed(2) - - const response = await serverFetch('/bridge/onramp/create-for-guest', { - method: 'POST', - body: JSON.stringify({ - amount, - userId: params.userId, - chargeId: params.chargeId, - source: { - currency, - paymentRail, - }, - }), - }) - - const data = await response.json() - - if (!response.ok) { - console.log('error', response) - return { error: data.error || 'Failed to create on-ramp transfer for guest.' } - } - - return { data } - } catch (error) { - console.error('Error calling create on-ramp for guest API:', error) - if (error instanceof Error) { - return { error: error.message } - } - return { error: 'An unexpected error occurred.' } - } -} diff --git a/src/utils/demo-api.ts b/src/utils/demo-api.ts index d90915246b..bd0d040331 100644 --- a/src/utils/demo-api.ts +++ b/src/utils/demo-api.ts @@ -382,7 +382,6 @@ const ROUTES: Array<{ method: string; pattern: string; handler: Handler }> = [ }), }, { method: 'POST', pattern: '/bridge/onramp/create', handler: () => ({ success: true }) }, - { method: 'POST', pattern: '/bridge/onramp/create-for-guest', handler: () => ({ success: true }) }, { method: 'DELETE', pattern: '/bridge/onramp/:transferId/cancel', handler: () => ({ success: true }) }, { method: 'POST', From c068189c375853e14a45d799fb669421792e9998 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Ram=C3=ADrez?= Date: Mon, 27 Jul 2026 15:54:55 -0300 Subject: [PATCH 2/3] feat(security): authenticate the charges websocket from the client 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. --- src/components/Home/HomeHistory.tsx | 15 +- src/services/websocket.ts | 66 +- src/types/api.generated.ts | 701 ++++++++---------- src/types/api.openapi.json | 1058 +++++++++++---------------- src/utils/auth-token.ts | 26 + 5 files changed, 800 insertions(+), 1066 deletions(-) diff --git a/src/components/Home/HomeHistory.tsx b/src/components/Home/HomeHistory.tsx index aa610bec28..b2abb923be 100644 --- a/src/components/Home/HomeHistory.tsx +++ b/src/components/Home/HomeHistory.tsx @@ -85,9 +85,20 @@ const HomeHistory = ({ // users who never got a card; only an issued card means they got through. const { overview: rainOverview } = useRainCardOverview() - // WebSocket for real-time updates + // WebSocket for real-time updates. + // + // Always subscribe to the SIGNED-IN user's own channel, never the `username` + // prop. On a public profile that prop is the profile owner, and this used to + // open a socket on their channel — which streamed their charge activity and + // KYC state to whoever was looking. The socket is now authenticated and the + // server rejects a channel that isn't yours, so passing someone else's + // username would simply fail to connect. + // + // The list itself is unaffected: it still comes from useTransactionHistory + // above, keyed on `username` with filterMutualTxs, which is what makes a + // profile show transactions mutual to the viewer. const { historyEntries: wsHistoryEntries } = useWebSocket({ - username, // Pass the username to the WebSocket hook + username: user?.user.username ?? undefined, onHistoryEntry: useCallback( (entry: HistoryEntry) => { const isCompleted = entry.status?.toUpperCase() === 'COMPLETED' diff --git a/src/services/websocket.ts b/src/services/websocket.ts index b471d07cce..1e302dbcd8 100644 --- a/src/services/websocket.ts +++ b/src/services/websocket.ts @@ -2,6 +2,7 @@ import { type HistoryEntry } from '@/hooks/useTransactionHistory' import { type PendingPerk } from '@/services/perks' import { isDemoMode } from '@/utils/demo' import { isCapacitor } from '@/utils/capacitor' +import { getSessionTokenForSocket } from '@/utils/auth-token' export type { PendingPerk } function isValidWsUrl(url: string): boolean { @@ -47,6 +48,9 @@ export type WebSocketMessage = { type: | 'ping' | 'pong' + // Handshake: the server sends one of these in reply to our `auth` frame. + | 'auth_ok' + | 'auth_error' | 'history_entry' | 'kyc_status_update' | 'manteca_kyc_status_update' @@ -64,6 +68,8 @@ export class PeanutWebSocket { private reconnectTimeout: NodeJS.Timeout | null = null private eventListeners: Map void>> = new Map() private isConnected = false + /** Set when the server refused our credential; suppresses the reconnect loop. */ + private authFailed = false private reconnectAttempts = 0 private readonly maxReconnectAttempts = 5 private readonly reconnectDelay = 3000 // 3 seconds @@ -82,6 +88,10 @@ export class PeanutWebSocket { return } + // A caller reconnecting deliberately (e.g. after a fresh login) gets a + // clean slate — the previous credential rejection should not stick. + this.authFailed = false + try { const fullUrl = new URL(this.path, this.url).toString() @@ -133,11 +143,33 @@ export class PeanutWebSocket { } } - private handleOpen(): void { + /** + * The server subscribes this socket to nothing until it receives an `auth` + * frame carrying a session JWT that resolves to the user in the path, so + * send it immediately on open. Async because native reads the token from + * the native cookie jar — which is exactly why auth is a message frame and + * not a header or query param. + */ + private async handleOpen(): Promise { this.isConnected = true this.reconnectAttempts = 0 this.startPingInterval() this.emit('connect', null) + + const token = await getSessionTokenForSocket() + if (!token) { + // No session to present. The server would drop us on its auth + // deadline anyway; close now and do not retry, or we would spin + // reconnecting a socket that can never authenticate. + this.authFailed = true + this.emit('auth_error', { reason: 'no-session' }) + this.disconnect() + return + } + + if (this.socket?.readyState === WebSocket.OPEN) { + this.socket.send(JSON.stringify({ type: 'auth', token })) + } } private handleMessage(event: MessageEvent): void { @@ -150,6 +182,20 @@ export class PeanutWebSocket { this.emit('pong', null) break + case 'auth_ok': + // Handshake accepted — event frames start flowing now. + this.authFailed = false + this.emit('auth_ok', null) + break + + case 'auth_error': + // The credential was refused, not the transport. Reconnecting + // would present the same bad token again, so mark it + // permanent; handleClose reads this to skip the backoff loop. + this.authFailed = true + this.emit('auth_error', message.data ?? null) + break + case 'history_entry': if (message.data) { // Process history entry @@ -210,11 +256,29 @@ export class PeanutWebSocket { } } + /** Server-side auth close codes (see peanut-api-ts routes/charges-ws). */ + private static readonly CLOSE_AUTH_FAILED = 4401 + private static readonly CLOSE_AUTH_TIMEOUT = 4408 + private handleClose(event: CloseEvent): void { this.isConnected = false this.clearPingInterval() this.emit('disconnect', { code: event.code, reason: event.reason }) + const authRejected = + this.authFailed || + event.code === PeanutWebSocket.CLOSE_AUTH_FAILED || + event.code === PeanutWebSocket.CLOSE_AUTH_TIMEOUT + + // An auth rejection arrives as a non-clean close, so without this check + // it would fall straight into the 5x exponential-backoff loop and + // hammer the API with a credential already known to be bad. + if (authRejected) { + this.authFailed = true + this.emit('auth_error', { code: event.code, reason: event.reason }) + return + } + if (!event.wasClean) { this.scheduleReconnect() } diff --git a/src/types/api.generated.ts b/src/types/api.generated.ts index f7ad1ff259..eedd903884 100644 --- a/src/types/api.generated.ts +++ b/src/types/api.generated.ts @@ -4,81 +4,6 @@ */ export interface paths { - "/notifications/admin/recent": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** Recent notification rows for a user (all channels + statuses) */ - get: { - parameters: { - query: { - userId: string; - limit: number; - }; - header: { - "x-admin-token": string; - }; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Default Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": { - items: { - id: string; - eventType: string; - channel: string; - status: "PENDING" | "SENT" | "FAILED" | "SKIPPED"; - skipReason: string | null; - providerId: string | null; - error: unknown; - createdAt: string; - sentAt: string | null; - }[]; - }; - }; - }; - /** @description Default Response */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": { - error: string; - }; - }; - }; - /** @description Default Response */ - 401: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": { - error: string; - }; - }; - }; - }; - }; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; "/": { parameters: { query?: never; @@ -359,7 +284,7 @@ export interface paths { content: { "application/json": { accountIdentifier: string; - accountType: "iban" | "us" | "evm-address" | "peanut-wallet" | "bridgeBankAccount" | "manteca" | "simplefi-merchant" | "clabe" | "cbu" | "cvu" | "pix"; + accountType: "iban" | "us" | "evm-address" | "peanut-wallet" | "bridgeBankAccount" | "manteca" | "clabe" | "cbu" | "cvu" | "pix"; userId: string; bridgeAccountIdentifier?: string; chainId?: string; @@ -564,7 +489,7 @@ export interface paths { patch?: never; trace?: never; }; - "/get-user-salt": { + "/update-user": { parameters: { query?: never; header?: never; @@ -576,7 +501,8 @@ export interface paths { post: { parameters: { query?: never; - header?: { + header: { + Authorization: string; "api-key"?: string; }; path?: never; @@ -585,7 +511,18 @@ export interface paths { requestBody: { content: { "application/json": { - email: string; + userId: string; + username?: string; + email?: string; + fullName?: string; + bridge_customer_id?: string; + telegramUsername?: string; + offrampHandle?: string; + pushSubscriptionId?: string; + showFullName?: boolean; + hasSeenEarlyUserModal?: boolean; + bridgeKycStatus?: "not_started" | "incomplete" | "under_review" | "approved" | "rejected"; + dismissActivationCelebration?: boolean; }; }; }; @@ -605,7 +542,7 @@ export interface paths { patch?: never; trace?: never; }; - "/update-user": { + "/users/me/delete": { parameters: { query?: never; header?: never; @@ -619,29 +556,11 @@ export interface paths { query?: never; header: { Authorization: string; - "api-key"?: string; }; path?: never; cookie?: never; }; - requestBody: { - content: { - "application/json": { - userId: string; - username?: string; - email?: string; - fullName?: string; - bridge_customer_id?: string; - telegramUsername?: string; - offrampHandle?: string; - pushSubscriptionId?: string; - showFullName?: boolean; - hasSeenEarlyUserModal?: boolean; - bridgeKycStatus?: "not_started" | "incomplete" | "under_review" | "approved" | "rejected"; - dismissActivationCelebration?: boolean; - }; - }; - }; + requestBody?: never; responses: { /** @description Default Response */ 200: { @@ -658,7 +577,7 @@ export interface paths { patch?: never; trace?: never; }; - "/users/me/delete": { + "/users/logout": { parameters: { query?: never; header?: never; @@ -770,48 +689,6 @@ export interface paths { patch?: never; trace?: never; }; - "/users/track-transaction": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - post: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: { - content: { - "application/json": { - payerAddress?: string; - txHash?: string; - sourceChainId?: string; - sourceTokenAddress?: string; - }; - }; - }; - responses: { - /** @description Default Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - }; - }; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; "/history/{entryId}": { parameters: { query?: never; @@ -849,44 +726,6 @@ export interface paths { patch?: never; trace?: never; }; - "/api/users/{userId}/acknowledgments": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get: { - parameters: { - query?: never; - header: { - Authorization: string; - "api-key"?: string; - }; - path: { - userId: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Default Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - }; - }; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; "/users/search": { parameters: { query?: never; @@ -1786,6 +1625,123 @@ export interface paths { patch?: never; trace?: never; }; + "/auth/step-up/options": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + rpID?: string; + }; + }; + }; + responses: { + /** @description Default Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/auth/step-up/verify": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + cred: unknown; + rpID?: string; + }; + }; + }; + responses: { + /** @description Default Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + token: string; + expiresIn: number; + }; + }; + }; + /** @description Default Response */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Default Response */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Default Response */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/requests": { parameters: { query?: never; @@ -2587,118 +2543,11 @@ export interface paths { }; path: { customerId: string; - externalAccountId: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Default Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - }; - }; - put?: never; - post?: never; - delete: { - parameters: { - query?: never; - header?: { - "api-key"?: string; - }; - path: { - customerId: string; - externalAccountId: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Default Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - }; - }; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/bridge/customers/{customerId}/external-accounts/{externalAccountId}/reactivate": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - post: { - parameters: { - query?: never; - header?: { - "api-key"?: string; - }; - path: { - customerId: string; - externalAccountId: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Default Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - }; - }; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/bridge/kyc-links": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - post: { - parameters: { - query?: never; - header?: { - "api-key"?: string; - }; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": { - type: "business" | "individual"; - fullName: string; - email: string; - redirectUri?: string; - isEEA?: boolean; - }; + externalAccountId: string; }; + cookie?: never; }; + requestBody?: never; responses: { /** @description Default Response */ 200: { @@ -2709,27 +2558,17 @@ export interface paths { }; }; }; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/bridge/customers/{uuid}/kyc-links": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get: { + put?: never; + post?: never; + delete: { parameters: { query?: never; header?: { "api-key"?: string; }; path: { - uuid: string; + customerId: string; + externalAccountId: string; }; cookie?: never; }; @@ -2744,35 +2583,29 @@ export interface paths { }; }; }; - put?: never; - post?: never; - delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/bridge/customers/{uuid}/transfers": { + "/bridge/customers/{customerId}/external-accounts/{externalAccountId}/reactivate": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: { + get?: never; + put?: never; + post: { parameters: { - query?: { - limit?: number; - startingAfter?: string; - endingBefore?: string; - updatedBeforeMs?: number; - updatedAfterMs?: number; - }; + query?: never; header?: { "api-key"?: string; }; path: { - uuid: string; + customerId: string; + externalAccountId: string; }; cookie?: never; }; @@ -2787,15 +2620,13 @@ export interface paths { }; }; }; - put?: never; - post?: never; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/bridge/transfers/{transferId}": { + "/bridge/customers/{uuid}/kyc-links": { parameters: { query?: never; header?: never; @@ -2809,7 +2640,7 @@ export interface paths { "api-key"?: string; }; path: { - transferId: string; + uuid: string; }; cookie?: never; }; @@ -3283,97 +3114,6 @@ export interface paths { patch?: never; trace?: never; }; - "/bridge/onramp/create-for-guest": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - /** - * Initiate an on-ramp transfer for a guest - * @description This endpoint initiates a new on-ramp transfer (fiat to crypto) for a guest. It creates a transfer from fiat to USDC on Arbitrum to the guest's peanut wallet and returns bank deposit instructions. - */ - post: { - parameters: { - query?: never; - header?: { - "api-key"?: string; - }; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": { - amount: string; - source: { - currency: "usd" | "eur" | "mxn" | "gbp"; - paymentRail: "ach" | "ach_push" | "ach_same_day" | "wire" | "sepa" | "swift" | "spei" | "faster_payments"; - }; - userId: string; - chargeId?: string; - }; - }; - }; - responses: { - /** @description Default Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": { - transferId: string; - depositInstructions: { - amount: string; - currency: string; - depositMessage: string; - bankName?: string; - bankAddress?: string; - bankRoutingNumber?: string; - bankAccountNumber?: string; - bankBeneficiaryName?: string; - bankBeneficiaryAddress?: string; - iban?: string; - bic?: string; - accountHolderName?: string; - }; - }; - }; - }; - /** @description Default Response */ - 401: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": { - error: string; - }; - }; - }; - /** @description Default Response */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": { - error: string; - }; - }; - }; - }; - }; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; "/bridge/onramp/quote": { parameters: { query?: never; @@ -4838,6 +4578,84 @@ export interface paths { patch?: never; trace?: never; }; + "/notifications/admin/recent": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Recent notification rows for a user (all channels + statuses) */ + get: { + parameters: { + query: { + userId: string; + limit: number; + }; + header: { + "x-admin-token": string; + }; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Default Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + items: { + id: string; + eventType: string; + channel: string; + status: "PENDING" | "SENT" | "FAILED" | "SKIPPED"; + skipReason: string | null; + providerId: string | null; + error: { + reason: string | null; + name: string | null; + } | null; + createdAt: string; + sentAt: string | null; + }[]; + }; + }; + }; + /** @description Default Response */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Default Response */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/notifications": { parameters: { query?: never; @@ -4937,6 +4755,60 @@ export interface paths { patch?: never; trace?: never; }; + "/unsubscribe": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: { + parameters: { + query: { + token: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Default Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + put?: never; + post: { + parameters: { + query: { + token: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Default Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/webhooks/onesignal": { parameters: { query?: never; @@ -6385,6 +6257,7 @@ export interface paths { spendingPower: number; inTransitToCollateralCents: number; }; + balanceUnavailable: boolean; cards: { id: string; rainCardId: string; @@ -8966,7 +8839,7 @@ export interface paths { content: { "application/json": { userId: string; - code: "BETA_TESTER" | "DEVCONNECT_BA_2025" | "PRODUCT_HUNT" | "OG_2025_10_12" | "SEEDLING_DEVCONNECT_BA_2025" | "ARBIVERSE_DEVCONNECT_BA_2025" | "CARD_PIONEER" | "FOUNDER_HOUSE" | "BUG_WHISPERER" | "SHHHHH" | "NOT_SO_SHHHH" | "CARD_FIRST_SWIPE" | "CARD_SPENT_1K" | "CARD_ALPHA" | "TOKEN_NATION_SP_2026" | "ETHFLORIPA_HUB" | "IRL_NOMADS" | "EVENT_ALUMNI" | "TOUCHED_GRASS" | "OFFRAMP_USER" | "PSYOPS_DIVISION" | "WAITLIST_SKIP" | "FESTA_JUNINA_2026"; + code: "BETA_TESTER" | "DEVCONNECT_BA_2025" | "PRODUCT_HUNT" | "OG_2025_10_12" | "SEEDLING_DEVCONNECT_BA_2025" | "ARBIVERSE_DEVCONNECT_BA_2025" | "CARD_PIONEER" | "FOUNDER_HOUSE" | "BUG_WHISPERER" | "SHHHHH" | "NOT_SO_SHHHH" | "CARD_FIRST_SWIPE" | "CARD_SPENT_1K" | "CARD_ALPHA" | "TOKEN_NATION_SP_2026" | "ETHFLORIPA_HUB" | "IRL_NOMADS" | "EVENT_ALUMNI" | "TOUCHED_GRASS" | "OFFRAMP_USER" | "PSYOPS_DIVISION" | "WAITLIST_SKIP" | "FESTA_JUNINA_2026" | "MANICERO"; revoke?: boolean; }; }; diff --git a/src/types/api.openapi.json b/src/types/api.openapi.json index 9bcce42741..0dd8008360 100644 --- a/src/types/api.openapi.json +++ b/src/types/api.openapi.json @@ -8,171 +8,6 @@ "schemas": {} }, "paths": { - "/notifications/admin/recent": { - "get": { - "summary": "Recent notification rows for a user (all channels + statuses)", - "tags": ["admin"], - "parameters": [ - { - "schema": { - "minLength": 1, - "type": "string" - }, - "in": "query", - "name": "userId", - "required": true - }, - { - "schema": { - "minimum": 1, - "maximum": 100, - "default": 20, - "type": "integer" - }, - "in": "query", - "name": "limit", - "required": true - }, - { - "schema": { - "type": "string" - }, - "in": "header", - "name": "x-admin-token", - "required": true - } - ], - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "items": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "eventType": { - "type": "string" - }, - "channel": { - "type": "string" - }, - "status": { - "anyOf": [ - { - "type": "string", - "enum": ["PENDING"] - }, - { - "type": "string", - "enum": ["SENT"] - }, - { - "type": "string", - "enum": ["FAILED"] - }, - { - "type": "string", - "enum": ["SKIPPED"] - } - ] - }, - "skipReason": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "providerId": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "error": {}, - "createdAt": { - "type": "string" - }, - "sentAt": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - } - }, - "required": [ - "id", - "eventType", - "channel", - "status", - "skipReason", - "providerId", - "error", - "createdAt", - "sentAt" - ] - } - } - }, - "required": ["items"] - } - } - } - }, - "400": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - }, - "required": ["error"] - } - } - } - }, - "401": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - }, - "required": ["error"] - } - } - } - } - } - } - }, "/": { "get": { "responses": { @@ -371,10 +206,6 @@ "type": "string", "enum": ["manteca"] }, - { - "type": "string", - "enum": ["simplefi-merchant"] - }, { "type": "string", "enum": ["clabe"] @@ -942,41 +773,6 @@ } } }, - "/get-user-salt": { - "post": { - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "email": { - "type": "string" - } - }, - "required": ["email"] - } - } - }, - "required": true - }, - "parameters": [ - { - "schema": { - "type": "string" - }, - "in": "header", - "name": "api-key", - "required": false - } - ], - "responses": { - "200": { - "description": "Default Response" - } - } - } - }, "/update-user": { "post": { "requestBody": { @@ -1094,6 +890,25 @@ } } }, + "/users/logout": { + "post": { + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "header", + "name": "Authorization", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, "/users/contacts": { "get": { "parameters": [ @@ -1180,38 +995,6 @@ } } }, - "/users/track-transaction": { - "post": { - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "payerAddress": { - "type": "string" - }, - "txHash": { - "type": "string" - }, - "sourceChainId": { - "type": "string" - }, - "sourceTokenAddress": { - "type": "string" - } - } - } - } - } - }, - "responses": { - "200": { - "description": "Default Response" - } - } - } - }, "/history/{entryId}": { "get": { "parameters": [ @@ -1239,42 +1022,7 @@ } } }, - "/api/users/{userId}/acknowledgments": { - "get": { - "parameters": [ - { - "schema": { - "type": "string" - }, - "in": "path", - "name": "userId", - "required": true - }, - { - "schema": { - "type": "string" - }, - "in": "header", - "name": "Authorization", - "required": true - }, - { - "schema": { - "type": "string" - }, - "in": "header", - "name": "api-key", - "required": false - } - ], - "responses": { - "200": { - "description": "Default Response" - } - } - } - }, - "/users/search": { + "/users/search": { "get": { "responses": { "200": { @@ -2313,6 +2061,119 @@ } } }, + "/auth/step-up/options": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "rpID": { + "type": "string" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/auth/step-up/verify": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "cred": {}, + "rpID": { + "type": "string" + } + }, + "required": ["cred"] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "token": { + "type": "string" + }, + "expiresIn": { + "type": "number" + } + }, + "required": ["token", "expiresIn"] + } + } + } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": ["error"] + } + } + } + }, + "401": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": ["error"] + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": ["error"] + } + } + } + } + } + } + }, "/requests": { "post": { "requestBody": { @@ -2377,8 +2238,8 @@ "type": "string" }, "tokenAddress": { - "type": "string", - "pattern": "^0x[a-fA-F0-9]{40}$" + "pattern": "^(0x[a-fA-F0-9]{40}|[1-9A-HJ-NP-Za-km-z]{32,44})$", + "type": "string" }, "tokenDecimals": { "type": "string" @@ -3551,62 +3412,6 @@ } } }, - "/bridge/kyc-links": { - "post": { - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "type": { - "anyOf": [ - { - "type": "string", - "enum": ["business"] - }, - { - "type": "string", - "enum": ["individual"] - } - ] - }, - "fullName": { - "type": "string" - }, - "email": { - "type": "string" - }, - "redirectUri": { - "type": "string" - }, - "isEEA": { - "type": "boolean" - } - }, - "required": ["type", "fullName", "email"] - } - } - }, - "required": true - }, - "parameters": [ - { - "schema": { - "type": "string" - }, - "in": "header", - "name": "api-key", - "required": false - } - ], - "responses": { - "200": { - "description": "Default Response" - } - } - } - }, "/bridge/customers/{uuid}/kyc-links": { "get": { "parameters": [ @@ -3634,100 +3439,6 @@ } } }, - "/bridge/customers/{uuid}/transfers": { - "get": { - "parameters": [ - { - "schema": { - "type": "number" - }, - "in": "query", - "name": "limit", - "required": false - }, - { - "schema": { - "type": "string" - }, - "in": "query", - "name": "startingAfter", - "required": false - }, - { - "schema": { - "type": "string" - }, - "in": "query", - "name": "endingBefore", - "required": false - }, - { - "schema": { - "type": "number" - }, - "in": "query", - "name": "updatedBeforeMs", - "required": false - }, - { - "schema": { - "type": "number" - }, - "in": "query", - "name": "updatedAfterMs", - "required": false - }, - { - "schema": { - "type": "string" - }, - "in": "path", - "name": "uuid", - "required": true - }, - { - "schema": { - "type": "string" - }, - "in": "header", - "name": "api-key", - "required": false - } - ], - "responses": { - "200": { - "description": "Default Response" - } - } - } - }, - "/bridge/transfers/{transferId}": { - "get": { - "parameters": [ - { - "schema": { - "type": "string" - }, - "in": "path", - "name": "transferId", - "required": true - }, - { - "schema": { - "type": "string" - }, - "in": "header", - "name": "api-key", - "required": false - } - ], - "responses": { - "200": { - "description": "Default Response" - } - } - } - }, "/bridge/offramp/create": { "post": { "summary": "Initiate an off-ramp transfer", @@ -4631,227 +4342,18 @@ } } }, - "400": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - }, - "required": ["error"] - } - } - } - }, - "401": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - }, - "required": ["error"] - } - } - } - }, - "403": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - }, - "required": ["error"] - } - } - } - }, - "404": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - }, - "required": ["error"] - } - } - } - } - } - } - }, - "/bridge/onramp/create-for-guest": { - "post": { - "summary": "Initiate an on-ramp transfer for a guest", - "tags": ["onramp"], - "description": "This endpoint initiates a new on-ramp transfer (fiat to crypto) for a guest. It creates a transfer from fiat to USDC on Arbitrum to the guest's peanut wallet and returns bank deposit instructions.", - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "amount": { - "type": "string" - }, - "source": { - "type": "object", - "properties": { - "currency": { - "anyOf": [ - { - "type": "string", - "enum": ["usd"] - }, - { - "type": "string", - "enum": ["eur"] - }, - { - "type": "string", - "enum": ["mxn"] - }, - { - "type": "string", - "enum": ["gbp"] - } - ] - }, - "paymentRail": { - "anyOf": [ - { - "type": "string", - "enum": ["ach"] - }, - { - "type": "string", - "enum": ["ach_push"] - }, - { - "type": "string", - "enum": ["ach_same_day"] - }, - { - "type": "string", - "enum": ["wire"] - }, - { - "type": "string", - "enum": ["sepa"] - }, - { - "type": "string", - "enum": ["swift"] - }, - { - "type": "string", - "enum": ["spei"] - }, - { - "type": "string", - "enum": ["faster_payments"] - } - ] - } - }, - "required": ["currency", "paymentRail"] - }, - "userId": { - "type": "string" - }, - "chargeId": { - "type": "string" - } - }, - "required": ["amount", "source", "userId"] - } - } - }, - "required": true - }, - "parameters": [ - { - "schema": { - "type": "string" - }, - "in": "header", - "name": "api-key", - "required": false - } - ], - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "transferId": { - "type": "string" - }, - "depositInstructions": { - "type": "object", - "properties": { - "amount": { - "type": "string" - }, - "currency": { - "type": "string" - }, - "depositMessage": { - "type": "string" - }, - "bankName": { - "type": "string" - }, - "bankAddress": { - "type": "string" - }, - "bankRoutingNumber": { - "type": "string" - }, - "bankAccountNumber": { - "type": "string" - }, - "bankBeneficiaryName": { - "type": "string" - }, - "bankBeneficiaryAddress": { - "type": "string" - }, - "iban": { - "type": "string" - }, - "bic": { - "type": "string" - }, - "accountHolderName": { - "type": "string" - } - }, - "required": ["amount", "currency", "depositMessage"] + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" } }, - "required": ["transferId", "depositInstructions"] + "required": ["error"] } } } @@ -4872,6 +4374,22 @@ } } }, + "403": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": ["error"] + } + } + } + }, "404": { "description": "Default Response", "content": { @@ -5879,7 +5397,7 @@ "get": { "summary": "Get deposit order status", "tags": ["manteca"], - "description": "Poll the status of a deposit order by its Manteca synthetic id. Used by the BRL PIX QR flow to detect completion (intent.status === COMPLETED) without leaving the user on a static QR. Read-only \u2014 the webhook/poller remain the authoritative completion trigger.", + "description": "Poll the status of a deposit order by its Manteca synthetic id. Used by the BRL PIX QR flow to detect completion (intent.status === COMPLETED) without leaving the user on a static QR. Read-only — the webhook/poller remain the authoritative completion trigger.", "parameters": [ { "schema": { @@ -6972,6 +6490,203 @@ } } }, + "/notifications/admin/recent": { + "get": { + "summary": "Recent notification rows for a user (all channels + statuses)", + "tags": ["admin"], + "parameters": [ + { + "schema": { + "minLength": 1, + "type": "string" + }, + "in": "query", + "name": "userId", + "required": true + }, + { + "schema": { + "minimum": 1, + "maximum": 100, + "default": 20, + "type": "integer" + }, + "in": "query", + "name": "limit", + "required": true + }, + { + "schema": { + "type": "string" + }, + "in": "header", + "name": "x-admin-token", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "eventType": { + "type": "string" + }, + "channel": { + "type": "string" + }, + "status": { + "anyOf": [ + { + "type": "string", + "enum": ["PENDING"] + }, + { + "type": "string", + "enum": ["SENT"] + }, + { + "type": "string", + "enum": ["FAILED"] + }, + { + "type": "string", + "enum": ["SKIPPED"] + } + ] + }, + "skipReason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "providerId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "error": { + "anyOf": [ + { + "type": "object", + "properties": { + "reason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": ["reason", "name"] + }, + { + "type": "null" + } + ] + }, + "createdAt": { + "type": "string" + }, + "sentAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "id", + "eventType", + "channel", + "status", + "skipReason", + "providerId", + "error", + "createdAt", + "sentAt" + ] + } + } + }, + "required": ["items"] + } + } + } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": ["error"] + } + } + } + }, + "401": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": ["error"] + } + } + } + } + } + } + }, "/notifications": { "get": { "responses": { @@ -6999,6 +6714,44 @@ } } }, + "/unsubscribe": { + "get": { + "tags": ["notifications"], + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "query", + "name": "token", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response" + } + } + }, + "post": { + "tags": ["notifications"], + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "query", + "name": "token", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, "/webhooks/onesignal": { "post": { "responses": { @@ -8280,7 +8033,7 @@ }, "/card/waitlist/state": { "get": { - "summary": "Get the user\u2019s current waitlist state", + "summary": "Get the user’s current waitlist state", "tags": ["card"], "responses": { "200": { @@ -8663,6 +8416,9 @@ } ] }, + "balanceUnavailable": { + "type": "boolean" + }, "cards": { "type": "array", "items": { @@ -8710,7 +8466,7 @@ } } }, - "required": ["status", "balance", "cards"] + "required": ["status", "balance", "balanceUnavailable", "cards"] } } } @@ -10664,7 +10420,7 @@ "post": { "summary": "Run one polling cycle synchronously", "tags": ["dev"], - "description": "Triggers runPollingCycle() on demand \u2014 used by the QA harness to observe provider state transitions without waiting for the 5-minute interval.", + "description": "Triggers runPollingCycle() on demand — used by the QA harness to observe provider state transitions without waiting for the 5-minute interval.", "responses": { "200": { "description": "Default Response", @@ -10884,7 +10640,7 @@ "post": { "summary": "Run KYC provider submission synchronously for the given user/applicant", "tags": ["dev"], - "description": "Drives Peanut's submitToProviders(userId, applicantId) synchronously, the same function the Sumsub status-processor calls after GREEN. Used by QA harness to test routing (AR user \u2192 Manteca, US user \u2192 Bridge) end-to-end.", + "description": "Drives Peanut's submitToProviders(userId, applicantId) synchronously, the same function the Sumsub status-processor calls after GREEN. Used by QA harness to test routing (AR user → Manteca, US user → Bridge) end-to-end.", "requestBody": { "content": { "application/json": { @@ -10952,7 +10708,7 @@ "post": { "summary": "Synthesise a Sumsub webhook and run the status-processor", "tags": ["dev"], - "description": "Invokes processSumsubKycUpdate({ applicantId, externalUserId, reviewAnswer, reviewStatus, source: 'webhook' }) \u2014 same entry point as the real /sumsub/webhooks route, minus HMAC. Used by the QA harness to exercise the full Sumsub \u2192 routing chain without real webhook delivery.", + "description": "Invokes processSumsubKycUpdate({ applicantId, externalUserId, reviewAnswer, reviewStatus, source: 'webhook' }) — same entry point as the real /sumsub/webhooks route, minus HMAC. Used by the QA harness to exercise the full Sumsub → routing chain without real webhook delivery.", "requestBody": { "content": { "application/json": { @@ -12114,6 +11870,10 @@ { "type": "string", "enum": ["FESTA_JUNINA_2026"] + }, + { + "type": "string", + "enum": ["MANICERO"] } ] }, diff --git a/src/utils/auth-token.ts b/src/utils/auth-token.ts index e8369c4892..00d200940a 100644 --- a/src/utils/auth-token.ts +++ b/src/utils/auth-token.ts @@ -35,6 +35,32 @@ export function setAuthToken(token: string): void { Cookies.set(JWT_COOKIE_KEY, token, { expires: 30, path: '/' }) } +/** + * Resolve the raw session token for the ONE consumer that cannot use any of + * the normal credential paths: the charges WebSocket. + * + * Every other call either rides the native cookie jar (CapacitorHttp attaches + * it automatically) or reads the web cookie via getAuthToken. A WebSocket can + * do neither: the browser API cannot set headers, the web `jwt-token` cookie is + * host-only on peanut.me so it is never sent to api.peanut.me, and on native + * the WebView's own WebSocket does not read CapacitorHttp's jar. The token + * therefore has to be handed to the socket explicitly, as its first frame. + * + * This is the deliberate exception to "JS is not a token custodian on native" + * in the header comment above — scoped to one caller, and the token goes + * straight into a frame rather than being stored anywhere. + */ +export async function getSessionTokenForSocket(): Promise { + 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 + } +} + /** * capacitor-only: does the native cookie jar hold a session cookie for the * API? Async because the jar lives on the native side. Used for cheap From 61ce168adabd6b378fee1fe3b61ff2bcc93156b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Ram=C3=ADrez?= <70615692+jjramirezn@users.noreply.github.com> Date: Mon, 27 Jul 2026 19:05:04 -0300 Subject: [PATCH 3/3] fix(ws): keep live entries off other people's profiles, and emit disconnect 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. --- src/components/Home/HomeHistory.tsx | 15 ++++++++++++--- src/services/websocket.ts | 8 ++++++++ 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/src/components/Home/HomeHistory.tsx b/src/components/Home/HomeHistory.tsx index b2abb923be..6fea766486 100644 --- a/src/components/Home/HomeHistory.tsx +++ b/src/components/Home/HomeHistory.tsx @@ -134,6 +134,15 @@ const HomeHistory = ({ ), }) + // Live entries only belong in the list when the list IS the signed-in + // user's. The socket is now always our OWN channel, so on someone else's + // profile `wsHistoryEntries` are the VIEWER's transactions — merging them + // would inject them into the profile owner's history. + const liveEntries = useMemo( + () => (isViewingOwnHistory ? wsHistoryEntries : []), + [isViewingOwnHistory, wsHistoryEntries] + ) + // Combine fetched history with real-time updates const [combinedEntries, setCombinedEntries] = useState>([]) @@ -186,7 +195,7 @@ const HomeHistory = ({ // process websocket entries: update existing or add new ones // Sort by timestamp ascending to process oldest entries first - const sortedWsEntries = [...wsHistoryEntries].sort( + const sortedWsEntries = [...liveEntries].sort( (a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime() ) @@ -288,7 +297,7 @@ const HomeHistory = ({ } } return undefined - }, [historyData, wsHistoryEntries, user, isLoading, isViewingOwnHistory, cardInfo, rainOverview]) + }, [historyData, liveEntries, user, isLoading, isViewingOwnHistory, cardInfo, rainOverview]) const pendingRequests = useMemo(() => { if (!combinedEntries.length) return [] @@ -353,7 +362,7 @@ const HomeHistory = ({ } // check source data directly — combinedEntries lags behind due to async processing - const hasSourceEntries = (historyData?.entries?.length ?? 0) > 0 || wsHistoryEntries.length > 0 + const hasSourceEntries = (historyData?.entries?.length ?? 0) > 0 || liveEntries.length > 0 // Synthetic entries (KYC region rows, card-unlock) live in // combinedEntries but NOT in source-side historyData/wsHistoryEntries. diff --git a/src/services/websocket.ts b/src/services/websocket.ts index 1e302dbcd8..5bcde54202 100644 --- a/src/services/websocket.ts +++ b/src/services/websocket.ts @@ -164,6 +164,14 @@ export class PeanutWebSocket { this.authFailed = true this.emit('auth_error', { reason: 'no-session' }) this.disconnect() + // `disconnect()` nulls onclose before closing, so handleClose — the + // only other place that emits 'disconnect' — never runs on this + // path. Emit it here or consumers that track connect/disconnect + // pairs (useWebSocket maps them straight onto its status) stay + // stuck reporting "connected" forever, with no reconnect scheduled + // to correct it. We already emitted 'connect' above: the socket did + // open, we are closing it again. + this.emit('disconnect', { code: 0, reason: 'no-session' }) return }