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/components/Home/HomeHistory.tsx b/src/components/Home/HomeHistory.tsx index aa610bec28..6fea766486 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' @@ -123,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>([]) @@ -175,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() ) @@ -277,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 [] @@ -342,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 b471d07cce..5bcde54202 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,41 @@ 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() + // `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 + } + + if (this.socket?.readyState === WebSocket.OPEN) { + this.socket.send(JSON.stringify({ type: 'auth', token })) + } } private handleMessage(event: MessageEvent): void { @@ -150,6 +190,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 +264,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 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',