From 89a23c60052da7b5869ed581f4695ef3c4dfc590 Mon Sep 17 00:00:00 2001 From: innolove-dev Date: Thu, 16 Jul 2026 20:16:32 +0100 Subject: [PATCH 01/36] fix(badges): sort a copy in BadgesRow instead of mutating the prop Array.prototype.sort is in-place, so the sortedBadges useMemo mutated the caller-owned badges array during render. Sort a copy so the memo callback is pure and safe under concurrent/StrictMode double-invocation. No user-visible change today: the only caller passes locally-owned state that is repopulated per mount, and the resulting order is the desired one anyway. --- src/components/Badges/BadgesRow.tsx | 2 +- .../Badges/__tests__/BadgesRow.test.tsx | 37 +++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) create mode 100644 src/components/Badges/__tests__/BadgesRow.test.tsx diff --git a/src/components/Badges/BadgesRow.tsx b/src/components/Badges/BadgesRow.tsx index 696550bda0..f13940f564 100644 --- a/src/components/Badges/BadgesRow.tsx +++ b/src/components/Badges/BadgesRow.tsx @@ -38,7 +38,7 @@ const BadgesRow = ({ badges, className, isSelfProfile = true }: BadgesRowProps) // sort by earnedAt, newest first const sortedBadges = useMemo(() => { - return badges.sort((a, b) => { + return [...badges].sort((a, b) => { const at = a.earnedAt ? new Date(a.earnedAt).getTime() : 0 const bt = b.earnedAt ? new Date(b.earnedAt).getTime() : 0 return bt - at diff --git a/src/components/Badges/__tests__/BadgesRow.test.tsx b/src/components/Badges/__tests__/BadgesRow.test.tsx new file mode 100644 index 0000000000..b5bd3cf17d --- /dev/null +++ b/src/components/Badges/__tests__/BadgesRow.test.tsx @@ -0,0 +1,37 @@ +import { render } from '@testing-library/react' +import type { ComponentProps } from 'react' +import BadgesRow from '@/components/Badges/BadgesRow' + +jest.mock('next/image', () => ({ + __esModule: true, + default: ({ unoptimized, fill, ...rest }: ComponentProps<'img'> & { unoptimized?: boolean; fill?: boolean }) => ( + + ), +})) + +jest.mock('@/components/Tooltip', () => ({ + Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}, +})) + +const badge = (code: string, earnedAt: string) => ({ + code, + name: code, + description: null, + iconUrl: null, + earnedAt, +}) + +describe('BadgesRow', () => { + it('does not mutate the badges array it is given', () => { + // Oldest first, so a newest-first sort has to reorder them. + const badges = [ + badge('OLDEST', '2024-01-01T00:00:00.000Z'), + badge('MIDDLE', '2024-06-01T00:00:00.000Z'), + badge('NEWEST', '2024-12-01T00:00:00.000Z'), + ] + + render() + + expect(badges.map((b) => b.code)).toEqual(['OLDEST', 'MIDDLE', 'NEWEST']) + }) +}) From fa8937eb7eb535d77b34bc033275f423c635e78f Mon Sep 17 00:00:00 2001 From: innolove-dev Date: Thu, 16 Jul 2026 21:02:33 +0100 Subject: [PATCH 02/36] fix(native): don't flag P0_TRANSFORMS pages as uncovered server routes The anti-rot scan only consulted ITEMS_TO_DISABLE, so a page whose server-only exports are already stripped by a P0_TRANSFORMS stub still failed the native build. This is live on main: (mobile-ui)/claim/page.tsx has force-dynamic and a matching stub, and the scan rejects it. Skip files handled by a transform, and cover the scan with a unit test. --- scripts/__tests__/native-build-scan.test.js | 57 +++++++++++++++++++++ scripts/native-build.js | 10 +++- 2 files changed, 66 insertions(+), 1 deletion(-) create mode 100644 scripts/__tests__/native-build-scan.test.js diff --git a/scripts/__tests__/native-build-scan.test.js b/scripts/__tests__/native-build-scan.test.js new file mode 100644 index 0000000000..cff8347b5d --- /dev/null +++ b/scripts/__tests__/native-build-scan.test.js @@ -0,0 +1,57 @@ +const fs = require('fs') +const path = require('path') +const Module = require('module') + +const SCRIPT_PATH = path.join(__dirname, '..', 'native-build.js') + +// native-build.js is a script, not a module: it calls main() at import time and +// exports nothing. Load the real source with that call stripped so the scan +// helpers can be asserted against the actual app tree. +function loadScriptInternals() { + const source = fs.readFileSync(SCRIPT_PATH, 'utf-8') + const withoutEntrypoint = source.replace(/\nmain\(\)\s*$/, '\n') + expect(withoutEntrypoint).not.toBe(source) + + const exposed = + withoutEntrypoint + + '\nmodule.exports = { isHandledByTransform, detectUncoveredServerRoutes, P0_TRANSFORMS, APP_DIR }\n' + + const mod = new Module(SCRIPT_PATH, null) + mod.filename = SCRIPT_PATH + mod.paths = Module._nodeModulePaths(path.dirname(SCRIPT_PATH)) + mod._compile(exposed, SCRIPT_PATH) + return mod.exports +} + +const { isHandledByTransform, detectUncoveredServerRoutes, P0_TRANSFORMS, APP_DIR } = loadScriptInternals() + +const toPosix = (p) => p.split(path.sep).join('/') + +describe('native build server-route scan', () => { + it('treats every P0_TRANSFORMS entry as handled', () => { + for (const transform of P0_TRANSFORMS) { + expect(isHandledByTransform(transform.path)).toBe(true) + } + }) + + // The scan passes `path.relative(APP_DIR, full)` into the predicate. If the + // predicate compared a different path shape it would silently never match. + it('matches the relative path shape the scan actually computes', () => { + for (const transform of P0_TRANSFORMS) { + const absolute = path.join(APP_DIR, transform.path) + expect(isHandledByTransform(path.relative(APP_DIR, absolute))).toBe(true) + } + }) + + it('does not suppress routes that are not transformed', () => { + expect(isHandledByTransform('some/other/page.tsx')).toBe(false) + expect(isHandledByTransform('api/foo/route.ts')).toBe(false) + expect(isHandledByTransform('(mobile-ui)/claim/layout.tsx')).toBe(false) + }) + + it('does not flag transformed pages as uncovered server routes', () => { + const transformed = new Set(P0_TRANSFORMS.map((t) => t.path)) + const offenders = detectUncoveredServerRoutes().filter((o) => transformed.has(toPosix(o.rel))) + expect(offenders).toEqual([]) + }) +}) diff --git a/scripts/native-build.js b/scripts/native-build.js index 09934ec733..31946721dd 100644 --- a/scripts/native-build.js +++ b/scripts/native-build.js @@ -248,6 +248,14 @@ function isCoveredByDisableList(relPath) { }) } +// P0_TRANSFORMS files are replaced with static-export-safe stubs before `next +// build`, so their server-only exports (generateMetadata, force-dynamic) never +// reach the export — the scan must not flag them. +function isHandledByTransform(relPath) { + const normalized = relPath.split(path.sep).join('/') + return P0_TRANSFORMS.some((t) => t.path === normalized) +} + function detectUncoveredServerRoutes(dir = APP_DIR, found = []) { for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { if (entry.name.includes('.disabled') || entry.name.startsWith('_')) continue @@ -258,7 +266,7 @@ function detectUncoveredServerRoutes(dir = APP_DIR, found = []) { detectUncoveredServerRoutes(full, found) continue } - if (isCoveredByDisableList(rel)) continue + if (isCoveredByDisableList(rel) || isHandledByTransform(rel)) continue if (entry.name === 'route.ts' || entry.name === 'route.js') { found.push({ rel, reason: 'route handler (cannot be statically exported)' }) continue From 9aad003f3640fe19780a3ddafa96bb23cc41c45a Mon Sep 17 00:00:00 2001 From: innolove-dev Date: Thu, 16 Jul 2026 17:18:46 +0100 Subject: [PATCH 03/36] fix(card): don't render '#null' for a waitlist entry without a position getPhysicalWaitlist types position as nullable and the joined branch is gated on joinedAt, so a user on the list with no queue position was told 'You are #null on the list.' Drop the number for that case and format real positions for readability. --- src/components/Card/PhysicalCardScreen.tsx | 4 +- .../__tests__/PhysicalCardScreen.test.tsx | 60 +++++++++++++++++++ 2 files changed, 63 insertions(+), 1 deletion(-) create mode 100644 src/components/Card/__tests__/PhysicalCardScreen.test.tsx diff --git a/src/components/Card/PhysicalCardScreen.tsx b/src/components/Card/PhysicalCardScreen.tsx index 8154976a6b..72a033da0c 100644 --- a/src/components/Card/PhysicalCardScreen.tsx +++ b/src/components/Card/PhysicalCardScreen.tsx @@ -76,7 +76,9 @@ const PhysicalCardScreen: FC = ({ cardId, last4, onPrev }) => {

You are on the list!

- {`You are #${data.position} on the list. We'll let you know when cards are ready to be shipped.`} + {data.position === null + ? `You are on the list. We'll let you know when cards are ready to be shipped.` + : `You are #${data.position.toLocaleString()} on the list. We'll let you know when cards are ready to be shipped.`}

) : ( diff --git a/src/components/Card/__tests__/PhysicalCardScreen.test.tsx b/src/components/Card/__tests__/PhysicalCardScreen.test.tsx new file mode 100644 index 0000000000..1d29b79772 --- /dev/null +++ b/src/components/Card/__tests__/PhysicalCardScreen.test.tsx @@ -0,0 +1,60 @@ +/** + * PhysicalCardScreen — waitlist "on the list" copy contract. + * + * The joined branch is gated on joinedAt, but the API types position as + * nullable, so a joined user can have no queue position. Interpolating it + * blindly renders "You are #null on the list." + */ +import React from 'react' +import { render, screen } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import PhysicalCardScreen from '@/components/Card/PhysicalCardScreen' +import { rainApi } from '@/services/rain' + +jest.mock('@/context/authContext', () => ({ + useAuth: () => ({ user: { accounts: [] }, fetchUser: jest.fn() }), +})) +jest.mock('next/image', () => ({ + __esModule: true, + // eslint-disable-next-line @next/next/no-img-element -- test stub, not real markup + default: (props: Record) => {String(props.alt, +})) +jest.mock('posthog-js', () => ({ __esModule: true, default: { capture: jest.fn() } })) +jest.mock('@/services/rain', () => ({ + rainApi: { getPhysicalWaitlist: jest.fn(), joinPhysicalWaitlist: jest.fn() }, +})) + +const mockGet = rainApi.getPhysicalWaitlist as jest.Mock + +const renderScreen = () => { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + return render( + + + + ) +} + +describe('PhysicalCardScreen — joined waitlist copy', () => { + beforeEach(() => jest.clearAllMocks()) + + it('shows the queue position when the API returns one', async () => { + mockGet.mockResolvedValue({ joinedAt: '2026-01-01T00:00:00Z', position: 42 }) + renderScreen() + expect(await screen.findByText(/You are #42 on the list\./)).toBeInTheDocument() + }) + + it('omits the position rather than rendering "#null" when there is none', async () => { + mockGet.mockResolvedValue({ joinedAt: '2026-01-01T00:00:00Z', position: null }) + renderScreen() + const body = await screen.findByText(/You are on the list\./) + expect(body).toBeInTheDocument() + expect(body.textContent).not.toMatch(/#/) + }) + + it('formats large positions with thousands separators', async () => { + mockGet.mockResolvedValue({ joinedAt: '2026-01-01T00:00:00Z', position: 1234 }) + renderScreen() + expect(await screen.findByText(/You are #1,234 on the list\./)).toBeInTheDocument() + }) +}) From 3b8466e3e9d7104f0aac1fba2527e1dc29b9a119 Mon Sep 17 00:00:00 2001 From: innolove-dev Date: Fri, 17 Jul 2026 13:54:29 +0100 Subject: [PATCH 04/36] test(card): derive the expected waitlist position from the active locale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hardcoded "1,234" assumed an en-US default locale, so the test failed under any locale with a different thousands separator (de_DE renders 1.234). Assert against toLocaleString output instead — that is the actual contract. --- src/components/Card/__tests__/PhysicalCardScreen.test.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/components/Card/__tests__/PhysicalCardScreen.test.tsx b/src/components/Card/__tests__/PhysicalCardScreen.test.tsx index 1d29b79772..c787501ce0 100644 --- a/src/components/Card/__tests__/PhysicalCardScreen.test.tsx +++ b/src/components/Card/__tests__/PhysicalCardScreen.test.tsx @@ -52,9 +52,12 @@ describe('PhysicalCardScreen — joined waitlist copy', () => { expect(body.textContent).not.toMatch(/#/) }) + // Derived rather than hardcoded: the separator is locale-dependent, and the + // contract is that the copy delegates to toLocaleString, not that it says "1,234". it('formats large positions with thousands separators', async () => { mockGet.mockResolvedValue({ joinedAt: '2026-01-01T00:00:00Z', position: 1234 }) renderScreen() - expect(await screen.findByText(/You are #1,234 on the list\./)).toBeInTheDocument() + const body = await screen.findByText(/on the list\./) + expect(body.textContent).toContain(`You are #${(1234).toLocaleString()} on the list.`) }) }) From bf3516e33ac8467c6ac1c2bc0ab1f1419d96e84d Mon Sep 17 00:00:00 2001 From: innolove-dev Date: Fri, 17 Jul 2026 17:39:18 +0100 Subject: [PATCH 05/36] chore(types): add FE types for GET /notifications/admin/recent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sync the OpenAPI snapshot + regenerated TS types with the new admin diagnostics endpoint added in peanut-api-ts (surface failed notification sends). Additive only — no existing paths change. --- src/types/api.generated.ts | 75 +++++++++++++++++ src/types/api.openapi.json | 165 +++++++++++++++++++++++++++++++++++++ 2 files changed, 240 insertions(+) diff --git a/src/types/api.generated.ts b/src/types/api.generated.ts index 318eb0f362..f7ad1ff259 100644 --- a/src/types/api.generated.ts +++ b/src/types/api.generated.ts @@ -4,6 +4,81 @@ */ 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; diff --git a/src/types/api.openapi.json b/src/types/api.openapi.json index fd63e10430..9bcce42741 100644 --- a/src/types/api.openapi.json +++ b/src/types/api.openapi.json @@ -8,6 +8,171 @@ "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": { From c8ee8c7f956b81b010b558b21c26516da7a42f62 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: Fri, 17 Jul 2026 17:24:04 -0300 Subject: [PATCH 06/36] feat(card): proof-of-address upload on stuck Rain applications MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hotfix port to main of peanut-ui#2438 (branch was dev-based; every touched file is byte-identical between dev and main, so this is the same reviewed content squashed onto main). Pairs with peanut-api-ts hotfix/rain-poa-selfheal — deploy the API first. When the rain rail carries the sumsub:proof_of_address next-action, the card status screens (requires-info / rejected) render a primary "Upload proof of address" CTA driving a dedicated lightweight flow: resubmit action -> second SumsubKycWrapper -> inline initiation errors -> optimistic "we received your document" banner (auto-reset when the backend state takes over). Double-click guard; analytics tagged source: poa-self-heal. Full review trail on #2438. --- src/app/(mobile-ui)/card/page.tsx | 94 ++++++++++++++++++- src/app/actions/sumsub.ts | 4 +- .../Card/ApplicationStatusScreen.tsx | 25 ++++- .../ApplicationStatusScreen.test.tsx | 53 +++++++++++ 4 files changed, 168 insertions(+), 8 deletions(-) diff --git a/src/app/(mobile-ui)/card/page.tsx b/src/app/(mobile-ui)/card/page.tsx index 17e3062aa2..1e39da95af 100644 --- a/src/app/(mobile-ui)/card/page.tsx +++ b/src/app/(mobile-ui)/card/page.tsx @@ -21,6 +21,7 @@ import Loading from '@/components/Global/Loading' import { Button } from '@/components/0_Bruddle/Button' import PageContainer from '@/components/0_Bruddle/PageContainer' import { SumsubKycWrapper } from '@/components/Kyc/SumsubKycWrapper' +import { initiateSelfHealResubmission } from '@/app/actions/sumsub' import { rainApi, type ApplyForCardResponse } from '@/services/rain' import { useGrantSessionKey } from '@/hooks/wallet/useGrantSessionKey' import { useCapabilities } from '@/hooks/useCapabilities' @@ -69,7 +70,7 @@ const CardPage: FC = () => { const { overview, isLoading: overviewLoading, error: overviewError } = useRainCardOverview() const { serializeGrant } = useGrantSessionKey() - const { railsForProvider, isLoading: capabilitiesLoading } = useCapabilities() + const { railsForProvider, nextActionsForRail, isLoading: capabilitiesLoading } = useCapabilities() const { setIsSupportModalOpen } = useModalsContext() const onBack = useSafeBack('/home') @@ -212,6 +213,62 @@ const CardPage: FC = () => { void queryClient.invalidateQueries({ queryKey: [RAIN_CARD_OVERVIEW_QUERY_KEY] }) }, [queryClient]) + // Proof-of-address self-heal — a dedicated, deliberately tiny flow. The + // multi-phase KYC machinery (useMultiPhaseKycFlow) is bank-onboarding + // shaped: its post-approval phase machine polls a mutating endpoint, can + // fan out to Bridge ToS modals, and completes on rail semantics that never + // match the Rain PoA lifecycle. Here we only need: mint an action token → + // open the SDK → on submit, thank the user and refetch. Separate surface + // from the card-application SumsubKycWrapper below — that one is driven by + // applyForCard tokens with its own refresh/poll semantics. + const [poaToken, setPoaToken] = useState(null) + const [poaError, setPoaError] = useState(null) + // Optimistic "we got your document" until the backend webhook flips the + // rail reason to its own review-wait state (may lag the SDK by seconds). + const [poaSubmitted, setPoaSubmitted] = useState(false) + + // The rain rail's self-serve proof-of-address action, when the backend + // classified the application as PoA-fixable (kind 'sumsub' + levelKey + // 'proof_of_address' — emitted for WRONG_ADDRESS-class denials). Absent → + // the status screens keep their contact-support-only shape. + const cardRail = railsForProvider('rain')[0] + const poaAction = cardRail + ? nextActionsForRail(cardRail.id).find( + (action) => action.kind === 'sumsub' && action.levelKey === 'proof_of_address' + ) + : undefined + // Double-click guard: two concurrent initiations race the backend's + // create-action idempotency into minting two Sumsub actions (the id + // collision path deliberately mints a suffixed fresh action). + const poaStartingRef = useRef(false) + const startPoaUpload = useCallback(async () => { + if (poaStartingRef.current) return + poaStartingRef.current = true + posthog.capture(ANALYTICS_EVENTS.CARD_SUMSUB_OPENED, { source: 'poa-self-heal' }) + setPoaError(null) + try { + const response = await initiateSelfHealResubmission('RAIN') + if (response.error || !response.data?.token) { + // Surfaced inline on the status screen — a silent primary CTA on + // a stuck-application screen is worse than no CTA. + setPoaError(response.error ?? 'Could not start the upload. Please try again.') + return + } + setPoaToken(response.data.token) + } finally { + poaStartingRef.current = false + } + }, []) + const onUploadProofOfAddress = poaAction && !poaSubmitted ? () => void startPoaUpload() : undefined + + // Once the backend's own state takes over (the rail's sumsub action gives + // way to the review-wait state, an approval, or a different ask), drop the + // optimistic banner so a later re-offered upload isn't suppressed until + // remount. + useEffect(() => { + if (poaSubmitted && !poaAction) setPoaSubmitted(false) + }, [poaSubmitted, poaAction]) + // Routes a non-incomplete apply response to the right next screen. Shared // by the user-initiated apply path and the post-Sumsub poll, since both // need the same main-kyc-required / terms-required / default fan-out. @@ -599,12 +656,16 @@ const CardPage: FC = () => { ) } - const cardRailReason = railsForProvider('rain')[0]?.reason?.userMessage + const cardRailReason = poaSubmitted + ? 'We received your proof of address — it’s being reviewed.' + : railsForProvider('rain')[0]?.reason?.userMessage return ( setIsSupportModalOpen(true)} + onUploadProofOfAddress={onUploadProofOfAddress} + uploadError={poaError ?? undefined} onPrev={onBack} /> ) @@ -633,14 +694,18 @@ const CardPage: FC = () => { // (meaningless without its reason), the rejected screen is useful // on its own (reassurance + support CTA), so show it now and let // the reason fill in once capabilities resolve. - const cardRailReason = capabilitiesLoading - ? undefined - : railsForProvider('rain')[0]?.reason?.userMessage + const cardRailReason = poaSubmitted + ? 'We received your proof of address — it’s being reviewed.' + : capabilitiesLoading + ? undefined + : railsForProvider('rain')[0]?.reason?.userMessage return ( setIsSupportModalOpen(true)} + onUploadProofOfAddress={onUploadProofOfAddress} + uploadError={poaError ?? undefined} onPrev={onBack} /> ) @@ -657,6 +722,25 @@ const CardPage: FC = () => { return ( {renderState()} + setPoaToken(null)} + onComplete={() => { + // Document submitted to Sumsub. Review + the backend webhook + // stamp happen async — flip the optimistic banner and refetch + // so the screen picks up the backend's wait state when ready. + setPoaToken(null) + setPoaSubmitted(true) + invalidateOverview() + void fetchUser() + }} + onRefreshToken={async () => { + const response = await initiateSelfHealResubmission('RAIN') + if (!response.data?.token) throw new Error(response.error ?? 'Failed to refresh token') + return response.data.token + }} + /> void + /** When the rail carries a self-serve proof-of-address action, this opens + * the Sumsub upload flow — rendered as the primary CTA so users fix it + * themselves instead of messaging support. */ + onUploadProofOfAddress?: () => void + /** Inline failure from starting the upload (a silent primary CTA on a + * stuck-application screen reads as broken). */ + uploadError?: string onPrev?: () => void } @@ -46,7 +54,14 @@ const COPY: Record = { /** Variants where support is the only path forward — these render the CTA. */ const SUPPORT_VARIANTS: ReadonlySet = new Set(['requires-info', 'requires-support', 'rejected']) -const ApplicationStatusScreen: FC = ({ variant, reasonMessage, onContactSupport, onPrev }) => { +const ApplicationStatusScreen: FC = ({ + variant, + reasonMessage, + onContactSupport, + onUploadProofOfAddress, + uploadError, + onPrev, +}) => { const copy = COPY[variant] return (
@@ -69,6 +84,14 @@ const ApplicationStatusScreen: FC = ({ variant, reasonMessage, onContactS {reasonMessage &&

{reasonMessage}

}

{copy.body}

+ {SUPPORT_VARIANTS.has(variant) && onUploadProofOfAddress && ( +
+ + {uploadError &&

{uploadError}

} +
+ )} {SUPPORT_VARIANTS.has(variant) && onContactSupport && ( + + + + ) +} diff --git a/src/utils/__tests__/app-lock.test.ts b/src/utils/__tests__/app-lock.test.ts new file mode 100644 index 0000000000..28c863dc37 --- /dev/null +++ b/src/utils/__tests__/app-lock.test.ts @@ -0,0 +1,65 @@ +/** + * The lock's safety property is asymmetric: failing to lock is a security + * weakness, but failing to UNLOCK strands the user in their own app with no + * way out. These tests pin which failure modes fall which way. + */ +import { webcrypto } from 'node:crypto' + +import { requestLocalUserPresence } from '../app-lock' + +if (!globalThis.crypto?.getRandomValues) { + Object.defineProperty(globalThis, 'crypto', { value: webcrypto, configurable: true }) +} + +const CREDENTIAL_ID = 'YWJjZGVmZ2g' + +function withCredentialsGet(impl: () => Promise) { + Object.defineProperty(globalThis, 'PublicKeyCredential', { value: function () {}, configurable: true }) + Object.defineProperty(globalThis.navigator, 'credentials', { + value: { get: impl }, + configurable: true, + }) +} + +describe('requestLocalUserPresence', () => { + it('reports unsupported when there is no stored credential to prompt against', async () => { + withCredentialsGet(async () => ({}) as Credential) + await expect(requestLocalUserPresence(undefined)).resolves.toBe('unsupported') + }) + + it('reports unsupported when the platform has no WebAuthn', async () => { + Object.defineProperty(globalThis, 'PublicKeyCredential', { value: undefined, configurable: true }) + await expect(requestLocalUserPresence(CREDENTIAL_ID)).resolves.toBe('unsupported') + }) + + it('unlocks when the authenticator returns an assertion', async () => { + withCredentialsGet(async () => ({}) as Credential) + await expect(requestLocalUserPresence(CREDENTIAL_ID)).resolves.toBe('unlocked') + }) + + it('stays locked when the user cancels the prompt', async () => { + withCredentialsGet(async () => { + throw Object.assign(new Error('cancelled'), { name: 'NotAllowedError' }) + }) + await expect(requestLocalUserPresence(CREDENTIAL_ID)).resolves.toBe('dismissed') + }) + + it('reports unsupported when the authenticator rejects the request outright', async () => { + withCredentialsGet(async () => { + throw Object.assign(new Error('nope'), { name: 'NotSupportedError' }) + }) + await expect(requestLocalUserPresence(CREDENTIAL_ID)).resolves.toBe('unsupported') + }) + + it('pins the request to the stored credential and demands user verification', async () => { + let received: PublicKeyCredentialRequestOptions | undefined + withCredentialsGet(async (options?: CredentialRequestOptions) => { + received = options?.publicKey + return {} as Credential + }) + await requestLocalUserPresence(CREDENTIAL_ID) + expect(received?.userVerification).toBe('required') + expect(received?.allowCredentials).toHaveLength(1) + expect(new Uint8Array(received!.challenge as ArrayBufferView['buffer'])).not.toHaveLength(0) + }) +}) diff --git a/src/utils/app-lock.ts b/src/utils/app-lock.ts new file mode 100644 index 0000000000..4b25d2a6c4 --- /dev/null +++ b/src/utils/app-lock.ts @@ -0,0 +1,53 @@ +// Native app lock: a local user-presence gate shown when the app is opened +// cold or resumed after a spell in the background. +// +// This is deliberately a LOCAL check — the assertion is never sent to the API +// for verification. The threat it addresses is physical: someone holding an +// already-unlocked phone. The OS will not produce an assertion without the +// user's biometric or device passcode, which is exactly the property we want. +// Server-side proof of a fresh assertion is a separate concern (step-up auth on +// sensitive endpoints) and does not belong in a UI lock. + +import { base64URLToBytes } from './native-webauthn' + +/** How long the app may sit in the background before it relocks. */ +export const LOCK_AFTER_BACKGROUND_MS = 5 * 60 * 1000 + +export type UnlockOutcome = 'unlocked' | 'dismissed' | 'unsupported' + +/** + * Prompts for the device biometric/passcode via a WebAuthn assertion against + * the user's existing passkey. + * + * Returns 'unsupported' when we cannot prompt at all — no WebAuthn, or no + * stored credential id. Callers must treat that as "do not lock": a lock we + * can't lift would strand the user in their own app with no way back. + */ +export async function requestLocalUserPresence(credentialId?: string): Promise { + if (typeof window === 'undefined') return 'unsupported' + if (!credentialId) return 'unsupported' + if (!window.PublicKeyCredential || !navigator.credentials?.get) return 'unsupported' + + const challenge = new Uint8Array(32) + crypto.getRandomValues(challenge) + + try { + const assertion = await navigator.credentials.get({ + publicKey: { + challenge, + // Copy into a fresh buffer: BufferSource wants Uint8Array, + // and base64URLToBytes is typed over the wider ArrayBufferLike. + allowCredentials: [{ id: new Uint8Array(base64URLToBytes(credentialId)), type: 'public-key' }], + userVerification: 'required', + timeout: 60_000, + }, + }) + return assertion ? 'unlocked' : 'dismissed' + } catch (error) { + // A cancelled prompt and a genuinely broken authenticator are + // indistinguishable here; both leave the app locked with a retry, which + // is the safe direction. Only the pre-flight checks above fail open. + if (error instanceof Error && error.name === 'NotSupportedError') return 'unsupported' + return 'dismissed' + } +} From c0fbae41558a5a7d024b13ddcd86a77b29288fb6 Mon Sep 17 00:00:00 2001 From: innolove-dev Date: Tue, 21 Jul 2026 16:06:40 +0100 Subject: [PATCH 25/36] feat(security): report-only CSP with a script-src allow-list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The app ships no script-src today, so an XSS anywhere runs unconstrained and can exfiltrate to any origin. Enforcing a guessed allow-list would blank the app, so this ships REPORT-ONLY: it collects violations from real traffic until the list is known complete, then gets promoted to the enforcing header. Reports go to Sentry via a report-uri derived from the browser DSN; the policy still ships (without reporting) when the DSN is absent. object-src 'none' and base-uri 'self' are added to the ENFORCING policy now — the app embeds no plugins and sets no , so neither can break a working page. Known-loose and to be tightened before promotion: 'unsafe-inline' and 'unsafe-eval' in script-src (Next's bootstrap and the wallet SDKs), and a connect-src that cannot enumerate every env-driven chain RPC. --- next.config.js | 80 +++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 79 insertions(+), 1 deletion(-) diff --git a/next.config.js b/next.config.js index 53c2ad6b51..41c7f1f361 100644 --- a/next.config.js +++ b/next.config.js @@ -5,6 +5,78 @@ const withBundleAnalyzer = const redirectsConfig = require('./redirects.json') +/** + * Sentry's CSP-report ingest endpoint, derived from the browser DSN + * (`https://@/`). Returns null when the DSN is + * absent or malformed, in which case the policy still ships — it just has + * nowhere to report, which is better than emitting a broken `report-uri`. + */ +function sentryCspReportUri() { + const dsn = process.env.NEXT_PUBLIC_SENTRY_DSN + if (!dsn) return null + try { + const { host, username, pathname } = new URL(dsn) + const projectId = pathname.replace(/^\//, '') + if (!host || !username || !projectId) return null + return `https://${host}/api/${projectId}/security/?sentry_key=${username}` + } catch { + return null + } +} + +/** + * First-draft CSP, shipped REPORT-ONLY. + * + * Nothing here is enforced yet: the app currently has no script-src at all, so + * an XSS anywhere is unconstrained. Guessing the allow-list and enforcing it + * would blank the app; instead this collects violation reports from real + * traffic until the list is known to be complete, then it gets promoted to the + * enforcing `Content-Security-Policy` header. + * + * Known-loose parts, to tighten before promotion: + * - `'unsafe-inline'` / `'unsafe-eval'` in script-src: Next's inline bootstrap + * and the wallet SDKs need them today. Moving to nonces is its own change. + * - connect-src can't enumerate every chain RPC (they come from env and vary by + * network), so the report stream is what completes this list. + */ +function contentSecurityPolicyReportOnly() { + const reportUri = sentryCspReportUri() + const directives = [ + "default-src 'self'", + // PostHog is same-origin via the /relay rewrite, so it needs no entry here. + "script-src 'self' 'unsafe-inline' 'unsafe-eval' https://www.googletagmanager.com https://client.crisp.chat https://static.sumsub.com", + "style-src 'self' 'unsafe-inline' https://client.crisp.chat", + "img-src 'self' data: blob: https:", + "font-src 'self' data: https://client.crisp.chat", + [ + "connect-src 'self'", + 'https://api.peanut.me', + 'https://*.peanut.me', + 'https://*.ingest.sentry.io', + 'https://*.ingest.us.sentry.io', + 'https://www.google-analytics.com', + 'https://rpc.zerodev.app', + 'https://*.g.alchemy.com', + 'https://rpc.ankr.com', + 'https://assets.coingecko.com', + 'https://coin-images.coingecko.com', + 'https://api.frankfurter.app', + 'https://dolarapi.com', + 'https://client.crisp.chat', + 'wss://client.relay.crisp.chat', + 'https://*.sumsub.com', + 'https://widget.manteca.dev', + ].join(' '), + "frame-src 'self' https://client.crisp.chat https://*.sumsub.com https://widget.manteca.dev https://mpago.la", + "worker-src 'self' blob:", + "object-src 'none'", + "base-uri 'self'", + "form-action 'self'", + ] + if (reportUri) directives.push(`report-uri ${reportUri}`) + return directives.join('; ') +} + // Get git commit hash at build time let gitCommitHash = 'unknown' try { @@ -186,7 +258,13 @@ let nextConfig = { }, // Security headers - prevents clickjacking and other attacks // Using frame-ancestors instead of X-Frame-Options to allow specific domains - { key: 'Content-Security-Policy', value: "frame-ancestors 'self' https://hugo0.com" }, + // object-src/base-uri are safe to enforce today: the app embeds no + // plugins and sets no , so neither can break a working page. + { + key: 'Content-Security-Policy', + value: "frame-ancestors 'self' https://hugo0.com; object-src 'none'; base-uri 'self'", + }, + { key: 'Content-Security-Policy-Report-Only', value: contentSecurityPolicyReportOnly() }, { key: 'X-Content-Type-Options', value: 'nosniff' }, { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' }, ], From 969d498a6b1fb4705e2d09f1f6b26adc8532b9a5 Mon Sep 17 00:00:00 2001 From: innolove-dev Date: Tue, 21 Jul 2026 16:44:13 +0100 Subject: [PATCH 26/36] feat(security): prove a fresh passkey assertion on sensitive actions Card PAN/CVV, PIN, withdrawals and bank-account add were authorized by the session cookie alone. They now carry an x-step-up-token proving a WebAuthn assertion from the last five minutes. The proof is cached for its lifetime, so a multi-step flow (approve then prepare) costs one Face ID prompt rather than one per request, and is dropped on logout so it can't outlive the session it belongs to. Rain calls opt in with stepUp: true on the single rainRequest choke point instead of threading a header through every call site. Pairs with peanut-api-ts, where enforcement stays behind STEP_UP_ENFORCED until a build carrying this ships. --- src/app/actions/users.ts | 2 + src/context/authContext.tsx | 5 ++ src/services/__tests__/step-up.test.ts | 103 +++++++++++++++++++++++++ src/services/rain.ts | 12 +++ src/services/step-up.ts | 77 ++++++++++++++++++ 5 files changed, 199 insertions(+) create mode 100644 src/services/__tests__/step-up.test.ts create mode 100644 src/services/step-up.ts diff --git a/src/app/actions/users.ts b/src/app/actions/users.ts index a57b505eca..0bed58670f 100644 --- a/src/app/actions/users.ts +++ b/src/app/actions/users.ts @@ -3,6 +3,7 @@ import { type AddBankAccountPayload, BridgeEndorsementType, type InitiateKycResp import { type CounterpartyUser } from '@/interfaces' import { type ContactsResponse } from '@/interfaces' import { serverFetch } from '@/utils/api-fetch' +import { withStepUpHeader } from '@/services/step-up' export const updateUserById = async (payload: Record): Promise<{ data?: ApiUser; error?: string }> => { try { @@ -51,6 +52,7 @@ export const addBankAccount = async (payload: AddBankAccountPayload): Promise<{ const response = await serverFetch('/users/accounts', { method: 'POST', body: JSON.stringify(payload), + headers: await withStepUpHeader({}), }) const responseJson = await response.json() diff --git a/src/context/authContext.tsx b/src/context/authContext.tsx index 1c13495230..652be925d0 100644 --- a/src/context/authContext.tsx +++ b/src/context/authContext.tsx @@ -25,6 +25,7 @@ import { createContext, type ReactNode, useContext, useState, useEffect, useMemo import { captureException, setUser as setSentryUser } from '@sentry/nextjs' // import { PUBLIC_ROUTES_REGEX } from '@/constants/routes' import { USER_DATA_CACHE_PATTERNS } from '@/constants/cache.consts' +import { clearStepUpToken } from '@/services/step-up' interface AuthContextType { user: IUserProfile | null @@ -201,6 +202,10 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => { // clear redirect url clearRedirectUrl() + // A cached step-up proof outliving the session would let the next user + // of this device skip verification on card and withdrawal screens. + clearStepUpToken() + // cancel + remove all queries to prevent refetches with cleared jwt try { queryClient.cancelQueries() diff --git a/src/services/__tests__/step-up.test.ts b/src/services/__tests__/step-up.test.ts new file mode 100644 index 0000000000..fadfaebe52 --- /dev/null +++ b/src/services/__tests__/step-up.test.ts @@ -0,0 +1,103 @@ +/** + * The cache is the risky part: too eager and one Face ID prompt unlocks + * sensitive routes indefinitely, too shy and a withdrawal prompts twice. + */ +import { clearStepUpToken, getStepUpToken, STEP_UP_HEADER, withStepUpHeader } from '../step-up' +import { apiFetch } from '@/utils/api-fetch' +import { startAuthentication } from '@simplewebauthn/browser' + +jest.mock('@/utils/api-fetch', () => ({ apiFetch: jest.fn() })) +jest.mock('@/utils/capacitor', () => ({ isCapacitor: () => false, getNativeRpId: () => 'peanut.me' })) + +const mockedFetch = apiFetch as jest.MockedFunction +const mockedAuth = startAuthentication as jest.MockedFunction + +function jsonResponse(body: unknown, ok = true, status = 200) { + return { ok, status, json: async () => body } as Response +} + +function happyPath(token = 'proof-token', expiresIn = 300) { + mockedFetch.mockReset() + mockedFetch + .mockResolvedValueOnce(jsonResponse({ challenge: 'abc' })) + .mockResolvedValueOnce(jsonResponse({ token, expiresIn })) +} + +describe('getStepUpToken', () => { + beforeEach(() => { + clearStepUpToken() + mockedAuth.mockReset() + mockedAuth.mockResolvedValue({ id: 'cred' } as never) + }) + + it('runs the ceremony and returns the proof token', async () => { + happyPath() + await expect(getStepUpToken()).resolves.toBe('proof-token') + expect(mockedFetch).toHaveBeenCalledTimes(2) + expect(mockedFetch.mock.calls[0][0]).toBe('/auth/step-up/options') + expect(mockedFetch.mock.calls[1][0]).toBe('/auth/step-up/verify') + }) + + it('reuses a live proof instead of prompting again', async () => { + happyPath() + await getStepUpToken() + await expect(getStepUpToken()).resolves.toBe('proof-token') + expect(mockedAuth).toHaveBeenCalledTimes(1) + }) + + it('re-prompts once the proof is close enough to expiry to be unusable', async () => { + happyPath('first', 20) // inside the 30s safety margin + await getStepUpToken() + happyPath('second') + await expect(getStepUpToken()).resolves.toBe('second') + expect(mockedAuth).toHaveBeenCalledTimes(2) + }) + + it('re-prompts after the cache is cleared (logout)', async () => { + happyPath('first') + await getStepUpToken() + clearStepUpToken() + happyPath('second') + await expect(getStepUpToken()).resolves.toBe('second') + }) + + it('explains the failure when the account has no passkey', async () => { + mockedFetch.mockReset() + mockedFetch.mockResolvedValueOnce(jsonResponse({ error: 'none' }, false, 404)) + await expect(getStepUpToken()).rejects.toThrow('No passkey is registered') + }) + + it('does not cache a proof when verification is rejected', async () => { + mockedFetch.mockReset() + mockedFetch + .mockResolvedValueOnce(jsonResponse({ challenge: 'abc' })) + .mockResolvedValueOnce(jsonResponse({ error: 'nope' }, false, 401)) + await expect(getStepUpToken()).rejects.toThrow('Could not confirm it is you') + + happyPath('after-retry') + await expect(getStepUpToken()).resolves.toBe('after-retry') + }) + + it('propagates a cancelled prompt rather than proceeding unverified', async () => { + mockedFetch.mockReset() + mockedFetch.mockResolvedValueOnce(jsonResponse({ challenge: 'abc' })) + mockedAuth.mockRejectedValueOnce(Object.assign(new Error('cancelled'), { name: 'NotAllowedError' })) + await expect(getStepUpToken()).rejects.toThrow('cancelled') + }) +}) + +describe('withStepUpHeader', () => { + beforeEach(() => { + clearStepUpToken() + mockedAuth.mockReset() + mockedAuth.mockResolvedValue({ id: 'cred' } as never) + }) + + it('adds the proof header while preserving existing ones', async () => { + happyPath() + await expect(withStepUpHeader({ 'Content-Type': 'application/json' })).resolves.toEqual({ + 'Content-Type': 'application/json', + [STEP_UP_HEADER]: 'proof-token', + }) + }) +}) diff --git a/src/services/rain.ts b/src/services/rain.ts index 9c7d5486f1..47d8c9a32f 100644 --- a/src/services/rain.ts +++ b/src/services/rain.ts @@ -11,6 +11,7 @@ import Cookies from 'js-cookie' import posthog from 'posthog-js' import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' import { PEANUT_API_KEY, PEANUT_API_URL } from '@/constants/general.consts' +import { getStepUpToken, STEP_UP_HEADER } from './step-up' import { fetchWithSentry } from '@/utils/sentry.utils' import type { SignedRainWithdrawal } from '@/hooks/wallet/useSignSpendBundle' @@ -322,6 +323,11 @@ interface RequestOpts { noStore?: boolean /** Override fetchWithSentry's default 10s timeout (e.g. UserOp submissions). */ timeoutMs?: number + /** + * Prove a fresh WebAuthn assertion alongside the session. Prompts for Face + * ID unless a proof from the last few minutes is still good. + */ + stepUp?: boolean } async function rainRequest(opts: RequestOpts): Promise { @@ -334,6 +340,7 @@ async function rainRequest(opts: RequestOpts): Promise { } if (opts.body !== undefined) headers['Content-Type'] = 'application/json' if (opts.noStore) headers['Cache-Control'] = 'no-store' + if (opts.stepUp) headers[STEP_UP_HEADER] = await getStepUpToken() const response = await fetchWithSentry( `${PEANUT_API_URL}${opts.path}`, @@ -430,6 +437,7 @@ export const rainApi = { await rainRequest<{ ok: boolean }>({ method: 'POST', path: '/rain/cards/withdraw/session-approve', + stepUp: true, body: input, }) }, @@ -443,6 +451,7 @@ export const rainApi = { return rainRequest({ method: 'POST', path: '/rain/cards/withdraw/prepare', + stepUp: true, body: input, }) }, @@ -646,6 +655,7 @@ export const rainApi = { return rainRequest({ method: 'GET', path: `/rain/cards/${cardId}/details`, + stepUp: true, rateLimitSensitive: true, noStore: true, }) @@ -678,6 +688,7 @@ export const rainApi = { const { pin } = await rainRequest<{ pin: string | null }>({ method: 'GET', path: `/rain/cards/${cardId}/pin`, + stepUp: true, rateLimitSensitive: true, noStore: true, }) @@ -689,6 +700,7 @@ export const rainApi = { await rainRequest<{ ok: boolean }>({ method: 'PUT', path: `/rain/cards/${cardId}/pin`, + stepUp: true, body: { pin }, noStore: true, }) diff --git a/src/services/step-up.ts b/src/services/step-up.ts new file mode 100644 index 0000000000..1c46a1c854 --- /dev/null +++ b/src/services/step-up.ts @@ -0,0 +1,77 @@ +/** + * Step-up authentication client. + * + * Sensitive routes (card PAN/CVV, PIN, withdrawal, bank-account add) want more + * than a week-old session cookie: they want proof the person is still holding + * the device. This runs a WebAuthn assertion against the user's own passkey and + * exchanges it for a short-lived proof token. + * + * The token is cached for its lifetime so a multi-step flow (approve → prepare) + * costs one Face ID prompt, not one per request. + */ + +import { startAuthentication } from '@simplewebauthn/browser' +import { apiFetch } from '@/utils/api-fetch' +import { getNativeRpId, isCapacitor } from '@/utils/capacitor' + +export const STEP_UP_HEADER = 'x-step-up-token' + +/** Retire the token early so a request never leaves with one about to expire. */ +const EXPIRY_MARGIN_MS = 30_000 + +let cached: { token: string; expiresAt: number } | null = null + +export class StepUpError extends Error { + constructor(message: string) { + super(message) + this.name = 'StepUpError' + } +} + +function currentRpId(): string { + return isCapacitor() ? getNativeRpId() : window.location.hostname.replace(/^www\./, '') +} + +/** Drops the cached proof. Call on logout, or after a 401 from a gated route. */ +export function clearStepUpToken(): void { + cached = null +} + +export async function getStepUpToken(): Promise { + if (cached && cached.expiresAt - EXPIRY_MARGIN_MS > Date.now()) return cached.token + cached = null + + const rpID = currentRpId() + + const optionsResponse = await apiFetch('/auth/step-up/options', { + method: 'POST', + body: JSON.stringify({ rpID }), + }) + if (!optionsResponse.ok) { + throw new StepUpError( + optionsResponse.status === 404 + ? 'No passkey is registered for this account.' + : 'Could not start verification.' + ) + } + const options = await optionsResponse.json() + + const cred = await startAuthentication(options) + + const verifyResponse = await apiFetch('/auth/step-up/verify', { + method: 'POST', + body: JSON.stringify({ cred, rpID }), + }) + if (!verifyResponse.ok) { + throw new StepUpError('Could not confirm it is you.') + } + + const { token, expiresIn } = (await verifyResponse.json()) as { token: string; expiresIn: number } + cached = { token, expiresAt: Date.now() + expiresIn * 1000 } + return token +} + +/** Adds the proof header, acquiring one if needed. */ +export async function withStepUpHeader(headers: Record): Promise> { + return { ...headers, [STEP_UP_HEADER]: await getStepUpToken() } +} From 331f20687be7efbade8f52ef22a542083f62fad7 Mon Sep 17 00:00:00 2001 From: innolove-dev Date: Tue, 21 Jul 2026 17:31:36 +0100 Subject: [PATCH 27/36] fix(review): preserve DSN protocol and path in the CSP report URI The report URI hardcoded https and dropped everything but the last path segment, so a path-prefixed DSN (self-hosted Sentry under a sub-path) posted reports to an endpoint that doesn't exist, and an http DSN was silently upgraded. Verified against a standard ingest DSN, a path-prefixed one, and a plain http localhost DSN. --- next.config.js | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/next.config.js b/next.config.js index 41c7f1f361..6d19b3063c 100644 --- a/next.config.js +++ b/next.config.js @@ -7,18 +7,24 @@ const redirectsConfig = require('./redirects.json') /** * Sentry's CSP-report ingest endpoint, derived from the browser DSN - * (`https://@/`). Returns null when the DSN is - * absent or malformed, in which case the policy still ships — it just has - * nowhere to report, which is better than emitting a broken `report-uri`. + * (`://@/`). Returns null when the + * DSN is absent or malformed, in which case the policy still ships — it just + * has nowhere to report, which is better than emitting a broken `report-uri`. + * + * Protocol and any path prefix are preserved: self-hosted Sentry is commonly + * mounted under a sub-path, and flattening one would silently post reports to + * an endpoint that doesn't exist. */ function sentryCspReportUri() { const dsn = process.env.NEXT_PUBLIC_SENTRY_DSN if (!dsn) return null try { - const { host, username, pathname } = new URL(dsn) - const projectId = pathname.replace(/^\//, '') + const { protocol, host, username, pathname } = new URL(dsn) + const segments = pathname.split('/').filter(Boolean) + const projectId = segments.pop() if (!host || !username || !projectId) return null - return `https://${host}/api/${projectId}/security/?sentry_key=${username}` + const prefix = segments.length ? `/${segments.join('/')}` : '' + return `${protocol}//${host}${prefix}/api/${projectId}/security/?sentry_key=${username}` } catch { return null } From e4db902937a01e7d89775000e2553cf5ad4e9c88 Mon Sep 17 00:00:00 2001 From: innolove-dev Date: Tue, 21 Jul 2026 17:37:53 +0100 Subject: [PATCH 28/36] fix(review): make the app lock a boundary, not an overlay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gate started unlocked and was mounted beside the page, so protected content could paint before the lock engaged and stayed focusable behind the overlay. It now wraps children and closes on the first client paint on native — before the user query can resolve and render balances. A 'pending' state covers the window where auth hasn't settled yet, resolving to 'open' for a signed-out user or one with no usable credential. While locked, the protected tree is not rendered at all, so nothing sits behind the lock screen for tab or screen reader to reach. Tradeoff: unlocking remounts, losing in-flight component state. The lock only fires on cold start (no state yet) or after five minutes background, where iOS has usually discarded the webview anyway. Also assert the pinned credential's actual bytes — a descriptor for the wrong credential passed the old length-only check. --- src/app/ClientProviders.tsx | 7 +- src/components/Global/AppLock/index.tsx | 132 ++++++++++++++++-------- src/utils/__tests__/app-lock.test.ts | 9 +- 3 files changed, 99 insertions(+), 49 deletions(-) diff --git a/src/app/ClientProviders.tsx b/src/app/ClientProviders.tsx index 2f995a8c5a..6bb0f37aca 100644 --- a/src/app/ClientProviders.tsx +++ b/src/app/ClientProviders.tsx @@ -56,15 +56,14 @@ export function ClientProviders({ children }: { children: React.ReactNode }) { {/* Non-intrusive "badge unlocked" toast on /home (TASK-19791). Global so it surfaces wherever the user lands after earning. */} - {/* Biometric gate for the native app on cold start and - long background. Renders nothing on web. */} - {HarnessBootstrap && ( )} - {children} + {/* Wraps rather than sits beside the page: while the + native app is locked, nothing protected renders. */} + {children} diff --git a/src/components/Global/AppLock/index.tsx b/src/components/Global/AppLock/index.tsx index 74cc66f7a4..0d5b956140 100644 --- a/src/components/Global/AppLock/index.tsx +++ b/src/components/Global/AppLock/index.tsx @@ -1,13 +1,20 @@ 'use client' /** - * Native app lock. Covers the app with a biometric gate on cold start and - * whenever it returns from more than LOCK_AFTER_BACKGROUND_MS in the - * background, so a session that outlives the user's attention doesn't hand the - * account to whoever picks the phone up next. + * Native app lock. Wraps the app on cold start and whenever it returns from + * more than LOCK_AFTER_BACKGROUND_MS in the background, so a session that + * outlives the user's attention doesn't hand the account to whoever picks the + * phone up next. + * + * It is a gate, not an overlay: while locked, the protected tree is not + * rendered at all. Nothing paints behind the lock screen and nothing back + * there is focusable or reachable by assistive tech. The cost is that + * remounting on unlock loses in-flight component state — acceptable, since the + * lock only fires on cold start (no state yet) or after five minutes + * backgrounded (where iOS has often discarded the webview anyway). * * Web is unaffected — there is no OS-backed presence check to lean on there, - * and the browser tab has no equivalent of "resumed from background". + * and a browser tab has no equivalent of "resumed from background". */ import { useCallback, useEffect, useRef, useState } from 'react' @@ -17,39 +24,81 @@ import { isCapacitor } from '@/utils/capacitor' import { getUserPreferences } from '@/utils/general.utils' import { LOCK_AFTER_BACKGROUND_MS, requestLocalUserPresence } from '@/utils/app-lock' -export function AppLockGate() { - const { user, logoutUser } = useAuth() +/** + * `pending` is the state that makes this a boundary rather than a curtain: on + * native we enter it on the very first client paint, before the user query can + * resolve and render balances. Only once auth settles do we learn whether this + * becomes `locked` or, for a signed-out user or one with no usable credential, + * `open`. + */ +type GateState = 'open' | 'pending' | 'locked' + +function LockScreen({ + failed, + unlocking, + onUnlock, + onLogout, +}: { + failed: boolean + unlocking: boolean + onUnlock: () => void + onLogout: () => void +}) { + return ( +
+
+

Peanut is locked

+

+ {failed ? 'Could not confirm it is you. Try again to continue.' : 'Confirm it is you to continue.'} +

+
+
+ + +
+
+ ) +} + +export function AppLockGate({ children }: { children: React.ReactNode }) { + const { user, isFetchingUser, logoutUser } = useAuth() const userId = user?.user.userId - const [locked, setLocked] = useState(false) + const [state, setState] = useState('open') const [unlocking, setUnlocking] = useState(false) const [failed, setFailed] = useState(false) const backgroundedAt = useRef(null) - // Cold-start lock must fire once per launch, not on every login state change. - const coldStartHandled = useRef(false) const credentialId = userId ? getUserPreferences(userId)?.webAuthnKey?.authenticatorId : undefined + // Close the gate on the first client paint, before anything protected can + // render. Runs once — later transitions are driven by resume or unlock. + useEffect(() => { + if (isCapacitor()) setState('pending') + }, []) + + useEffect(() => { + if (state !== 'pending' || isFetchingUser) return + // Nothing to protect, or no credential we could ever prompt against — + // a gate we can't open would strand the user in their own app. + setState(userId && credentialId ? 'locked' : 'open') + }, [state, isFetchingUser, userId, credentialId]) + const attemptUnlock = useCallback(async () => { setUnlocking(true) const outcome = await requestLocalUserPresence(credentialId) setUnlocking(false) - // 'unsupported' means we can never raise this lock — never leave the - // user staring at a gate with no key. if (outcome === 'unlocked' || outcome === 'unsupported') { - setLocked(false) setFailed(false) + setState('open') return } setFailed(true) }, [credentialId]) - useEffect(() => { - if (!isCapacitor() || !userId || coldStartHandled.current) return - coldStartHandled.current = true - if (!credentialId) return - setLocked(true) - }, [userId, credentialId]) - useEffect(() => { if (!isCapacitor() || !userId || !credentialId) return @@ -66,8 +115,8 @@ export function AppLockGate() { const since = backgroundedAt.current backgroundedAt.current = null if (since !== null && Date.now() - since > LOCK_AFTER_BACKGROUND_MS) { - setLocked(true) setFailed(false) + setState('locked') } }) ) @@ -80,7 +129,7 @@ export function AppLockGate() { }) .catch(() => { // No @capacitor/app bridge (web bundle, or an old native shell): - // resume-locking is simply unavailable. Cold-start lock still works. + // resume-locking is unavailable. Cold-start locking still works. }) return () => { @@ -89,33 +138,28 @@ export function AppLockGate() { } }, [userId, credentialId]) - // Auto-prompt as soon as the gate goes up, so the common case is one Face ID + // Prompt as soon as the gate closes, so the common case is one Face ID // prompt and no taps at all. useEffect(() => { - if (locked && !unlocking && !failed) void attemptUnlock() - // attemptUnlock is stable for a given credential; re-running on every - // render would re-prompt in a loop. + if (state === 'locked' && !unlocking && !failed) void attemptUnlock() + // Deliberately keyed on `state` alone: including attemptUnlock or the + // transient flags would re-fire the prompt in a loop. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [locked]) + }, [state]) + + if (state === 'open') return <>{children} - if (!locked) return null + // 'pending': auth hasn't settled, so we don't yet know whether to prompt. + // Show the bare cover rather than the "locked" copy, which would be a lie + // for a signed-out user about to be let straight through. + if (state === 'pending') return
return ( -
-
-

Peanut is locked

-

- {failed ? 'Could not confirm it is you. Try again to continue.' : 'Confirm it is you to continue.'} -

-
-
- - -
-
+ void attemptUnlock()} + onLogout={() => void logoutUser()} + /> ) } diff --git a/src/utils/__tests__/app-lock.test.ts b/src/utils/__tests__/app-lock.test.ts index 28c863dc37..e5d8558fb2 100644 --- a/src/utils/__tests__/app-lock.test.ts +++ b/src/utils/__tests__/app-lock.test.ts @@ -6,6 +6,7 @@ import { webcrypto } from 'node:crypto' import { requestLocalUserPresence } from '../app-lock' +import { base64URLToBytes } from '../native-webauthn' if (!globalThis.crypto?.getRandomValues) { Object.defineProperty(globalThis, 'crypto', { value: webcrypto, configurable: true }) @@ -60,6 +61,12 @@ describe('requestLocalUserPresence', () => { await requestLocalUserPresence(CREDENTIAL_ID) expect(received?.userVerification).toBe('required') expect(received?.allowCredentials).toHaveLength(1) - expect(new Uint8Array(received!.challenge as ArrayBufferView['buffer'])).not.toHaveLength(0) + // Assert the actual bytes, not just the count: a descriptor for the + // WRONG credential would satisfy a length check while letting some + // other passkey on the device open the lock. + expect(Array.from(new Uint8Array(received!.allowCredentials![0].id as ArrayBuffer))).toEqual( + Array.from(base64URLToBytes(CREDENTIAL_ID)) + ) + expect(new Uint8Array(received!.challenge as ArrayBuffer)).not.toHaveLength(0) }) }) From b52b2bc6bb1b3f6f0cf61bc1a6a35a06db85030b Mon Sep 17 00:00:00 2001 From: innolove-dev Date: Wed, 22 Jul 2026 10:42:13 +0100 Subject: [PATCH 29/36] fix(review): route rainRequest auth through apiFetch Reading the jwt cookie directly wrongly threw 'Authentication required' on native, where JS never holds the token and the native cookie jar authenticates requests. apiFetch is the path every other service uses: Authorization header on web, cookie jar on Capacitor, demo-mode routing included. --- src/services/rain.ts | 38 +++++++++++++++++--------------------- 1 file changed, 17 insertions(+), 21 deletions(-) diff --git a/src/services/rain.ts b/src/services/rain.ts index 47d8c9a32f..2ebd1be8b1 100644 --- a/src/services/rain.ts +++ b/src/services/rain.ts @@ -7,12 +7,13 @@ * (via `js-cookie`) — matches the pattern in `services/manteca.ts`. */ -import Cookies from 'js-cookie' import posthog from 'posthog-js' import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' -import { PEANUT_API_KEY, PEANUT_API_URL } from '@/constants/general.consts' +import { PEANUT_API_KEY } from '@/constants/general.consts' import { getStepUpToken, STEP_UP_HEADER } from './step-up' -import { fetchWithSentry } from '@/utils/sentry.utils' +import { apiFetch } from '@/utils/api-fetch' +import { getAuthToken } from '@/utils/auth-token' +import { isCapacitor } from '@/utils/capacitor' import type { SignedRainWithdrawal } from '@/hooks/wallet/useSignSpendBundle' // ─── Types ────────────────────────────────────────────────────────────────── @@ -321,7 +322,7 @@ interface RequestOpts { rateLimitSensitive?: boolean /** Mirror PCI no-cache intent on the client fetch for secrets endpoints. */ noStore?: boolean - /** Override fetchWithSentry's default 10s timeout (e.g. UserOp submissions). */ + /** Override the default 10s fetch timeout (e.g. UserOp submissions). */ timeoutMs?: number /** * Prove a fresh WebAuthn assertion alongside the session. Prompts for Face @@ -331,27 +332,22 @@ interface RequestOpts { } async function rainRequest(opts: RequestOpts): Promise { - const jwt = Cookies.get('jwt-token') - if (!jwt) throw new Error('Authentication required') + // Auth rides apiFetch: Authorization header on web, the native cookie jar + // on Capacitor — reading the cookie here would wrongly 401 native, where + // JS never holds the token. + if (!isCapacitor() && !getAuthToken()) throw new Error('Authentication required') - const headers: Record = { - Authorization: `Bearer ${jwt}`, - 'api-key': PEANUT_API_KEY, - } - if (opts.body !== undefined) headers['Content-Type'] = 'application/json' + const headers: Record = { 'api-key': PEANUT_API_KEY } if (opts.noStore) headers['Cache-Control'] = 'no-store' if (opts.stepUp) headers[STEP_UP_HEADER] = await getStepUpToken() - const response = await fetchWithSentry( - `${PEANUT_API_URL}${opts.path}`, - { - method: opts.method, - headers, - body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined, - cache: 'no-store', - }, - opts.timeoutMs - ) + const response = await apiFetch(opts.path, { + method: opts.method, + headers, + body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined, + cache: 'no-store', + timeoutMs: opts.timeoutMs, + }) if (response.status === 429 && opts.rateLimitSensitive) { const err = await response.json().catch(() => ({})) From e3b07788e5a678ee421f002d675d4391027856a5 Mon Sep 17 00:00:00 2001 From: innolove-dev Date: Wed, 22 Jul 2026 10:42:40 +0100 Subject: [PATCH 30/36] feat(review): deliver CSP reports via report-to as well as report-uri report-uri alone is deprecated and Chromium is where most violations will come from once report-to is the only mechanism; shipping both (plus the Reporting-Endpoints header the report-to group resolves against) keeps the violation stream complete across engines, so the eventual promotion to enforcing isn't decided on partial data. --- next.config.js | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/next.config.js b/next.config.js index 6d19b3063c..b48d2cd5a7 100644 --- a/next.config.js +++ b/next.config.js @@ -79,10 +79,23 @@ function contentSecurityPolicyReportOnly() { "base-uri 'self'", "form-action 'self'", ] - if (reportUri) directives.push(`report-uri ${reportUri}`) + // Both delivery mechanisms on purpose: `report-uri` is deprecated but what + // Firefox/Safari actually send today, `report-to` (backed by the + // Reporting-Endpoints header below) is what replaces it in Chromium. + // Shipping only one would undercount violations and promote the policy on + // a partial picture. + if (reportUri) directives.push(`report-uri ${reportUri}`, `report-to ${CSP_REPORT_GROUP}`) return directives.join('; ') } +const CSP_REPORT_GROUP = 'csp-endpoint' + +function reportingEndpointsHeader() { + const reportUri = sentryCspReportUri() + if (!reportUri) return [] + return [{ key: 'Reporting-Endpoints', value: `${CSP_REPORT_GROUP}="${reportUri}"` }] +} + // Get git commit hash at build time let gitCommitHash = 'unknown' try { @@ -271,6 +284,7 @@ let nextConfig = { value: "frame-ancestors 'self' https://hugo0.com; object-src 'none'; base-uri 'self'", }, { key: 'Content-Security-Policy-Report-Only', value: contentSecurityPolicyReportOnly() }, + ...reportingEndpointsHeader(), { key: 'X-Content-Type-Options', value: 'nosniff' }, { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' }, ], From 2131b43956965b72ea7d19bf6a29f7fa99eafc6d Mon Sep 17 00:00:00 2001 From: innolove-dev Date: Wed, 22 Jul 2026 10:43:56 +0100 Subject: [PATCH 31/36] docs(review): state the app lock's full fail-open surface honestly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The catch block's NotSupportedError path also resolves to unsupported (which the gate treats as unlock), so 'only the pre-flight checks fail open' undersold it. Also spell out in the module header that this is a privacy screen, not account protection — the session token is untouched by this PR and the gate disappears if the stored credential id is stripped. --- src/utils/app-lock.ts | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/src/utils/app-lock.ts b/src/utils/app-lock.ts index 4b25d2a6c4..050f1750e2 100644 --- a/src/utils/app-lock.ts +++ b/src/utils/app-lock.ts @@ -7,6 +7,13 @@ // user's biometric or device passcode, which is exactly the property we want. // Server-side proof of a fresh assertion is a separate concern (step-up auth on // sensitive endpoints) and does not belong in a UI lock. +// +// It is a privacy screen and a deterrent, NOT account protection: the session +// token stays where it always was (webview storage / cookie jar), reachable by +// anything that can read the app's filesystem, and clearing the stored +// credential id disables the gate entirely. Making it a genuine control means +// moving the token into biometric-guarded Keychain/Keystore — tracked +// separately. import { base64URLToBytes } from './native-webauthn' @@ -19,9 +26,13 @@ export type UnlockOutcome = 'unlocked' | 'dismissed' | 'unsupported' * Prompts for the device biometric/passcode via a WebAuthn assertion against * the user's existing passkey. * - * Returns 'unsupported' when we cannot prompt at all — no WebAuthn, or no - * stored credential id. Callers must treat that as "do not lock": a lock we - * can't lift would strand the user in their own app with no way back. + * Returns 'unsupported' when we cannot prompt at all — no WebAuthn, no stored + * credential id, or the authenticator rejecting the request outright + * (NotSupportedError). Callers must treat that as "do not lock": a lock we + * can't lift would strand the user in their own app with no way back. That + * makes every 'unsupported' path fail OPEN — this gate is a presence check for + * an honest user, not a security boundary against someone who can strip the + * stored credential id (see the module comment). */ export async function requestLocalUserPresence(credentialId?: string): Promise { if (typeof window === 'undefined') return 'unsupported' @@ -46,7 +57,9 @@ export async function requestLocalUserPresence(credentialId?: string): Promise Date: Wed, 22 Jul 2026 13:32:23 +0100 Subject: [PATCH 32/36] fix(native): route push notification taps inside the app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tapping a push left the app: OneSignal fires its own ACTION_VIEW/openURL for the notification's launch URL, so Android only re-entered the app for paths in the App Links filter, and iOS — which won't re-enter an app for its own universal link — always bounced to Safari. Suppress launch URLs on both platforms and route the tap from the click listener in useNativePlugins, reusing the App Links deep-link path. That covers destinations outside the intent filter and doesn't depend on link verification. deepLinkToNativePath now accepts a bare path (push payloads carry one), rejects off-domain links instead of rewriting them, and maps two destinations that had no native route: /receipt/ and /?chargeId=. /receipt/[entryId] is a server component stripped from the static export, so add a client twin at (mobile-ui)/receipt reading ?id=&kind= — receipts are the most common push destination and previously 404'd in-app. Also widen App Links to /history, /rewards, /badges and /profile (email CTA targets that still open the browser today), and map absolute peanut.me hrefs in the in-app inbox back to in-app paths. --- android/app/src/main/AndroidManifest.xml | 17 ++++++ ios/App/App/Info.plist | 6 ++ src/app/(mobile-ui)/notifications/page.tsx | 7 +++ src/app/(mobile-ui)/receipt/page.tsx | 65 ++++++++++++++++++++++ src/hooks/useNativePlugins.ts | 18 ++++++ src/utils/__tests__/native-routes.test.ts | 59 ++++++++++++++++++++ src/utils/native-routes.ts | 43 +++++++++++--- 7 files changed, 207 insertions(+), 8 deletions(-) create mode 100644 src/app/(mobile-ui)/receipt/page.tsx diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index b9932357b4..aa03f6dede 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -59,6 +59,14 @@ + + + + + + + + @@ -78,6 +86,15 @@ + + + + OneSignal_suppress_launch_urls + UILaunchStoryboardName LaunchScreen UIMainStoryboardFile diff --git a/src/app/(mobile-ui)/notifications/page.tsx b/src/app/(mobile-ui)/notifications/page.tsx index a471b20eb1..51cfdacb63 100644 --- a/src/app/(mobile-ui)/notifications/page.tsx +++ b/src/app/(mobile-ui)/notifications/page.tsx @@ -7,6 +7,7 @@ import NavHeader from '@/components/Global/NavHeader' import PeanutLoading from '@/components/Global/PeanutLoading' import { notificationsApi, type InAppItem } from '@/services/notifications' import { formatGroupHeaderDate, getDateGroup, getDateGroupKey } from '@/utils/dateGrouping.utils' +import { deepLinkToNativePath } from '@/utils/native-routes' import React, { useEffect, useMemo, useRef, useState } from 'react' import Image from 'next/image' import { PEANUTMAN } from '@/assets' @@ -157,7 +158,13 @@ export default function NotificationsPage() { if (group.items.length === 1) position = 'single' else if (idx === 0) position = 'first' else if (idx === group.items.length - 1) position = 'last' + // Rows minted from the OneSignal-dashboard webhook store the + // operator's `url` verbatim, so an absolute peanut.me link would + // navigate the webview off-app. Map those back to an in-app path; + // genuinely external links fall through untouched. const href = notif.ctaDeeplink + ? (deepLinkToNativePath(notif.ctaDeeplink) ?? notif.ctaDeeplink) + : notif.ctaDeeplink return ( ?kind=X onto this route's query params. +export default function NativeReceiptPage() { + const router = useRouter() + const searchParams = useSearchParams() + const entryId = searchParams.get('id') + const kind = resolveReceiptKind(searchParams.get('kind') ?? undefined, searchParams.get('t') ?? undefined) + + const { + data: entry, + isLoading, + isError, + } = useQuery({ + queryKey: [TRANSACTIONS, 'entry', entryId, kind], + enabled: Boolean(entryId && kind), + queryFn: async (): Promise => { + const response = await apiFetch(`/history/${encodeURIComponent(entryId!)}?kind=${kind}`) + if (!response.ok) throw new Error(`Failed to fetch history entry: ${response.status}`) + return completeHistoryEntry(await response.json()) + }, + // A pending transaction can still settle while the receipt is open. + refetchInterval: (query) => (query.state.data && !isFinalState(query.state.data) ? 15_000 : false), + }) + + useEffect(() => { + if (!entryId || !kind || isError) router.replace('/home') + }, [entryId, kind, isError, router]) + + if (!entryId || !kind || isError) return null + + return ( + +
+ +
+
+ {isLoading || !entry ? ( + + ) : ( + + )} +
+
+ ) +} diff --git a/src/hooks/useNativePlugins.ts b/src/hooks/useNativePlugins.ts index 557c94f92d..075baece92 100644 --- a/src/hooks/useNativePlugins.ts +++ b/src/hooks/useNativePlugins.ts @@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation' import { isCapacitor, getPlatform } from '@/utils/capacitor' import { deepLinkToNativePath } from '@/utils/native-routes' import { sanitizeRedirectURL } from '@/utils/general.utils' +import { getOneSignalAdapter } from '@/services/onesignal' /** * initializes capacitor native plugins (back button, status bar, splash screen). @@ -50,6 +51,23 @@ export function useNativePlugins() { console.warn('failed to init app listeners:', e) } + try { + // Push taps: the OneSignal SDKs are configured not to open the + // launch URL themselves (suppressLaunchURLs / OneSignal_suppress_launch_urls), + // so routing is ours. `additionalData.deepLink` is the canonical + // relative path the API sends; the launch URL is the fallback for + // notifications sent before that field existed. + const adapter = await getOneSignalAdapter() + cleanups.push( + adapter.onNotificationClick(({ deepLink, additionalData }) => { + const target = additionalData.deepLink + openDeepLink(typeof target === 'string' ? target : deepLink) + }) + ) + } catch (e) { + console.warn('failed to init notification click listener:', e) + } + try { const { StatusBar, Style } = await import('@capacitor/status-bar') await StatusBar.setOverlaysWebView({ overlay: false }) diff --git a/src/utils/__tests__/native-routes.test.ts b/src/utils/__tests__/native-routes.test.ts index 2f995266fc..35c78d7baa 100644 --- a/src/utils/__tests__/native-routes.test.ts +++ b/src/utils/__tests__/native-routes.test.ts @@ -20,6 +20,7 @@ import { withdrawCountryUrl, withdrawBankUrl, rewriteMethodPath, + deepLinkToNativePath, } from '../native-routes' describe('native-routes', () => { @@ -290,4 +291,62 @@ describe('native-routes', () => { }) }) }) + + describe('deepLinkToNativePath', () => { + describe('capacitor mode', () => { + beforeEach(() => { + mockIsCapacitor.mockReturnValue(true) + }) + + it('accepts a bare path — push payloads carry the deep link without a host', () => { + expect(deepLinkToNativePath('/receipt/intent-1?kind=ONRAMP')).toBe('/receipt?id=intent-1&kind=ONRAMP') + }) + + it('accepts a full App-Links url', () => { + expect(deepLinkToNativePath('https://peanut.me/receipt/intent-1?kind=ONRAMP')).toBe( + '/receipt?id=intent-1&kind=ONRAMP' + ) + }) + + it('maps a charge deep link onto the pay-request stand-in for the disabled catch-all route', () => { + expect(deepLinkToNativePath('/alice?chargeId=charge-123')).toBe('/pay-request?chargeId=charge-123') + }) + + it('leaves a static in-app route untouched', () => { + expect(deepLinkToNativePath('https://peanut.me/history')).toBe('/history') + }) + + it('still rewrites dynamic routes to their query-param form', () => { + expect(deepLinkToNativePath('https://peanut.me/send/bob')).toBe('/send?recipient=bob') + expect(deepLinkToNativePath('https://peanut.me/qr/abc123')).toBe('/qr?code=abc123') + expect(deepLinkToNativePath('https://peanut.me/withdraw/be/bank')).toBe( + '/withdraw?country=be&view=bank' + ) + }) + + it('rejects an off-domain url rather than rewriting it into an in-app path', () => { + expect(deepLinkToNativePath('https://evil.com/receipt/intent-1')).toBeNull() + }) + + it('returns null for an unparseable link', () => { + expect(deepLinkToNativePath('http://')).toBeNull() + }) + }) + + describe('web mode', () => { + beforeEach(() => { + mockIsCapacitor.mockReturnValue(false) + }) + + it('keeps the path-based receipt url', () => { + expect(deepLinkToNativePath('https://peanut.me/receipt/intent-1?kind=ONRAMP')).toBe( + '/receipt/intent-1?kind=ONRAMP' + ) + }) + + it('keeps a profile charge link on the profile route', () => { + expect(deepLinkToNativePath('/alice?chargeId=charge-123')).toBe('/alice?chargeId=charge-123') + }) + }) + }) }) diff --git a/src/utils/native-routes.ts b/src/utils/native-routes.ts index 95e118a822..00cb79f554 100644 --- a/src/utils/native-routes.ts +++ b/src/utils/native-routes.ts @@ -4,6 +4,12 @@ import { isCapacitor } from './capacitor' +// Deep links are peanut.me links by definition — that's the host the Android +// App Links filter and the AASA are bound to. Deliberately not derived from +// NEXT_PUBLIC_BASE_URL: a build pointed at a preview origin must still route a +// real peanut.me notification link. +const APP_HOSTS = /^(.+\.)?peanut\.me$/ + export function profileUrl(username: string): string { return isCapacitor() ? `/send?recipient=${encodeURIComponent(username)}` : `/${username}` } @@ -64,21 +70,29 @@ export function withdrawBankUrl(countryPath: string, queryParams?: string): stri } /** - * Maps an incoming App-Links URL (https://peanut.me/) to the path the - * native static export can actually render. Dynamic web routes are funnelled - * through the same query-param helpers used to build outbound links, so - * `/send/` → `/send?recipient=`, `/qr/` → `/qr?code=`, - * `/add-money//bank` → `/add-money?country=&view=bank`, etc. - * Static routes (e.g. `/card`, `/pay-request`) pass through unchanged. - * Returns null for an unparseable URL. + * Maps an incoming deep link — an App-Links URL (https://peanut.me/) or a + * bare path from a push payload — to the path the native static export can + * actually render. Dynamic web routes are funnelled through the same query-param + * helpers used to build outbound links, so `/send/` → `/send?recipient=`, + * `/qr/` → `/qr?code=`, `/add-money//bank` → + * `/add-money?country=&view=bank`, etc. Static routes (e.g. `/card`, + * `/pay-request`) pass through unchanged. + * + * Returns null for an unparseable link or one pointing at another host — an + * off-domain URL must stay off-domain rather than be rewritten into a bogus + * in-app path. */ export function deepLinkToNativePath(url: string): string | null { let parsed: URL try { - parsed = new URL(url) + // Relative base: push payloads carry the canonical path (`/receipt/`), + // App Links carry the full URL. Both must resolve. + parsed = new URL(url, 'https://peanut.me') } catch { return null } + if (!APP_HOSTS.test(parsed.hostname)) return null + const path = parsed.pathname const extraParams = parsed.search.replace(/^\?/, '') const segments = path.split('/').filter(Boolean) @@ -96,6 +110,19 @@ export function deepLinkToNativePath(url: string): string | null { if (segments[0] === 'add-money' || segments[0] === 'withdraw') { return rewriteMethodPath(path, extraParams || undefined) } + // `/receipt/?kind=X` — the web receipt page is a server component and is + // stripped from the static export (scripts/native-build.js), so native routes + // to the client variant. `kind` rides along in extraParams. + if (segments[0] === 'receipt' && segments[1]) { + const id = decodeURIComponent(segments[1]) + return appendParams(isCapacitor() ? `/receipt?id=${encodeURIComponent(id)}` : path, extraParams) + } + // `/?chargeId=` — the catch-all profile route is disabled in + // native builds; /pay-request is its stand-in (see (mobile-ui)/pay-request). + if (isCapacitor() && segments.length === 1) { + const chargeId = parsed.searchParams.get('chargeId') + if (chargeId) return chargePayUrl(chargeId) + } return appendParams(path, extraParams) } From cf4ba72027d9e27a70d4b01d8419ead60064be63 Mon Sep 17 00:00:00 2001 From: innolove-dev Date: Wed, 22 Jul 2026 14:33:15 +0100 Subject: [PATCH 33/36] fix(native): harden deep-link mapping against malformed and reserved paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes from review: deepLinkToNativePath only guarded the URL parse, but decodeURIComponent throws URIError on a stray '%'. This PR made the function reachable from a render path (the notifications list calls it inside .map), so one malformed ctaDeeplink would have thrown during render and blanked the page for everyone. Guard the whole parse-and-map body so bad input degrades to null. The single-segment chargeId branch treated any one-segment path as a username, so /rewards?chargeId=x would have been rewritten to /pay-request — and this PR is what added /rewards, /history, /badges and /profile to App Links. Gate it on the same isReservedRoute + couldBeRecipient rules the web catch-all already uses. On the receipt screen, isError also trips on a failed background poll, so a flaky refetch bounced the user to /home mid-settlement. Bail out only when there's no data at all. --- src/app/(mobile-ui)/receipt/page.tsx | 11 +++++++--- src/utils/__tests__/native-routes.test.ts | 25 +++++++++++++++++++++++ src/utils/native-routes.ts | 19 ++++++++++++----- 3 files changed, 47 insertions(+), 8 deletions(-) diff --git a/src/app/(mobile-ui)/receipt/page.tsx b/src/app/(mobile-ui)/receipt/page.tsx index 6227a53e86..4c64cccea0 100644 --- a/src/app/(mobile-ui)/receipt/page.tsx +++ b/src/app/(mobile-ui)/receipt/page.tsx @@ -39,11 +39,16 @@ export default function NativeReceiptPage() { refetchInterval: (query) => (query.state.data && !isFinalState(query.state.data) ? 15_000 : false), }) + // `isError` also trips on a failed background poll, so gate the bail-out on + // having no data at all — a flaky refetch must not yank the user off a + // receipt they're watching settle. + const isUnrecoverable = !entryId || !kind || (isError && !entry) + useEffect(() => { - if (!entryId || !kind || isError) router.replace('/home') - }, [entryId, kind, isError, router]) + if (isUnrecoverable) router.replace('/home') + }, [isUnrecoverable, router]) - if (!entryId || !kind || isError) return null + if (isUnrecoverable) return null return ( diff --git a/src/utils/__tests__/native-routes.test.ts b/src/utils/__tests__/native-routes.test.ts index 35c78d7baa..fca3e571e3 100644 --- a/src/utils/__tests__/native-routes.test.ts +++ b/src/utils/__tests__/native-routes.test.ts @@ -331,6 +331,31 @@ describe('native-routes', () => { it('returns null for an unparseable link', () => { expect(deepLinkToNativePath('http://')).toBeNull() }) + + // This runs during render in the notifications list, so a throw here + // would blank the whole page rather than drop one bad row. + it.each([ + ['receipt id', '/receipt/%E0%A4%A'], + ['send recipient', '/send/%E0%A4%A'], + ['qr code', '/qr/%'], + ['bare username', '/%'], + ])('never throws on malformed percent-encoding in the %s', (_label, link) => { + expect(() => deepLinkToNativePath(link)).not.toThrow() + }) + + it.each(['/receipt/%E0%A4%A', '/send/%E0%A4%A', '/qr/%'])( + 'degrades %s to null when the decode fails mid-mapping', + (link) => { + expect(deepLinkToNativePath(link)).toBeNull() + } + ) + + it.each(['/rewards', '/history', '/badges', '/profile'])( + 'leaves the reserved route %s alone even with a chargeId param', + (route) => { + expect(deepLinkToNativePath(`${route}?chargeId=charge-123`)).toBe(`${route}?chargeId=charge-123`) + } + ) }) describe('web mode', () => { diff --git a/src/utils/native-routes.ts b/src/utils/native-routes.ts index 00cb79f554..8a40b79c9e 100644 --- a/src/utils/native-routes.ts +++ b/src/utils/native-routes.ts @@ -2,6 +2,7 @@ // in capacitor (static export), dynamic routes don't work — use query params instead. // on web, use the normal path-based urls. +import { couldBeRecipient, isReservedRoute } from '@/constants/routes' import { isCapacitor } from './capacitor' // Deep links are peanut.me links by definition — that's the host the Android @@ -83,14 +84,20 @@ export function withdrawBankUrl(countryPath: string, queryParams?: string): stri * in-app path. */ export function deepLinkToNativePath(url: string): string | null { - let parsed: URL try { - // Relative base: push payloads carry the canonical path (`/receipt/`), - // App Links carry the full URL. Both must resolve. - parsed = new URL(url, 'https://peanut.me') + return mapDeepLink(url) } catch { + // The whole body is guarded, not just the URL parse: decodeURIComponent + // throws URIError on a stray `%`, and this runs during render in the + // notifications list — one malformed ctaDeeplink would blank the page. return null } +} + +function mapDeepLink(url: string): string | null { + // Relative base: push payloads carry the canonical path (`/receipt/`), + // App Links carry the full URL. Both must resolve. + const parsed = new URL(url, 'https://peanut.me') if (!APP_HOSTS.test(parsed.hostname)) return null const path = parsed.pathname @@ -119,7 +126,9 @@ export function deepLinkToNativePath(url: string): string | null { } // `/?chargeId=` — the catch-all profile route is disabled in // native builds; /pay-request is its stand-in (see (mobile-ui)/pay-request). - if (isCapacitor() && segments.length === 1) { + // Gated by the same reserved-route/recipient rules the web catch-all uses, so + // `/rewards?chargeId=x` stays on /rewards instead of landing on /pay-request. + if (isCapacitor() && segments.length === 1 && !isReservedRoute(path) && couldBeRecipient(segments[0])) { const chargeId = parsed.searchParams.get('chargeId') if (chargeId) return chargePayUrl(chargeId) } From 86e0766fbe1650a87be24f94a81f9584200646c5 Mon Sep 17 00:00:00 2001 From: innolove-dev Date: Wed, 22 Jul 2026 15:15:41 +0100 Subject: [PATCH 34/36] fix(native): buffer cold-start push clicks, open external links in browser Two gaps in the push tap routing: - Capacitor retains a cold-start click only until the first JS listener attaches, which adapter.init() (useNotifications) does on its own async path. If it wins the race against useNativePlugins registering the routing callback, the retained tap was consumed by an empty listener set. The adapter now holds the last unconsumed click and replays it to the next listener. - With launch URLs suppressed, an off-domain https deep link (operator sends) opened the app and went nowhere. Hand those to the system browser instead. --- src/hooks/useNativePlugins.ts | 12 ++- src/services/onesignal/native.adapter.test.ts | 80 +++++++++++++++++++ src/services/onesignal/native.adapter.ts | 18 +++++ 3 files changed, 109 insertions(+), 1 deletion(-) create mode 100644 src/services/onesignal/native.adapter.test.ts diff --git a/src/hooks/useNativePlugins.ts b/src/hooks/useNativePlugins.ts index 075baece92..d794a37545 100644 --- a/src/hooks/useNativePlugins.ts +++ b/src/hooks/useNativePlugins.ts @@ -61,7 +61,17 @@ export function useNativePlugins() { cleanups.push( adapter.onNotificationClick(({ deepLink, additionalData }) => { const target = additionalData.deepLink - openDeepLink(typeof target === 'string' ? target : deepLink) + const link = typeof target === 'string' ? target : deepLink + // Off-domain https links (operator sends) can't route in-app; + // with launch URLs suppressed, hand them to the system browser + // rather than silently swallowing the tap. + if (link && !deepLinkToNativePath(link) && /^https:\/\//i.test(link)) { + import('@capacitor/browser') + .then(({ Browser }) => Browser.open({ url: link })) + .catch((e) => console.warn('failed to open external push link:', e)) + return + } + openDeepLink(link) }) ) } catch (e) { diff --git a/src/services/onesignal/native.adapter.test.ts b/src/services/onesignal/native.adapter.test.ts new file mode 100644 index 0000000000..9dba3ac4ee --- /dev/null +++ b/src/services/onesignal/native.adapter.test.ts @@ -0,0 +1,80 @@ +import type { NotificationClickInfo } from './types' + +// virtual: the plugin ships ESM-only exports jest's resolver can't load; +// the adapter only ever runs in native builds where webpack resolves it. +jest.mock( + '@onesignal/capacitor-plugin', + () => ({ + __esModule: true, + default: { + initialize: jest.fn().mockResolvedValue(undefined), + login: jest.fn(), + logout: jest.fn(), + Notifications: { + addEventListener: jest.fn(), + hasPermission: jest.fn().mockResolvedValue(true), + canRequestPermission: jest.fn().mockResolvedValue(true), + requestPermission: jest.fn(), + }, + User: { + pushSubscription: { + addEventListener: jest.fn(), + getOptedInAsync: jest.fn().mockResolvedValue(true), + }, + }, + }, + }), + { virtual: true } +) + +import OneSignal from '@onesignal/capacitor-plugin' +import { nativeOneSignalAdapter } from './native.adapter' + +function getClickCallback(): (event: unknown) => void { + const call = (OneSignal.Notifications.addEventListener as jest.Mock).mock.calls.find(([name]) => name === 'click') + expect(call).toBeDefined() + return call![1] +} + +describe('nativeOneSignalAdapter cold-start click buffering', () => { + beforeAll(async () => { + process.env.NEXT_PUBLIC_ONESIGNAL_APP_ID = 'test-app-id' + await nativeOneSignalAdapter.init() + }) + + // The Capacitor bridge retains a cold-start click only until the first JS + // listener attaches — which init() does. If the event arrives before + // useNativePlugins has registered its routing callback, the adapter must + // hold it and replay it, or the tap is silently dropped. + it('replays a click that fired before any listener registered', () => { + const fireClick = getClickCallback() + fireClick({ + notification: { + launchURL: 'https://peanut.me/receipt/intent-1?kind=ONRAMP', + additionalData: { deepLink: '/receipt/intent-1?kind=ONRAMP' }, + }, + }) + + const seen: NotificationClickInfo[] = [] + const off = nativeOneSignalAdapter.onNotificationClick((info) => seen.push(info)) + expect(seen).toEqual([ + { + deepLink: 'https://peanut.me/receipt/intent-1?kind=ONRAMP', + additionalData: { deepLink: '/receipt/intent-1?kind=ONRAMP' }, + }, + ]) + + // one-shot: a second listener must not receive the same tap again + const seenLater: NotificationClickInfo[] = [] + const offLater = nativeOneSignalAdapter.onNotificationClick((info) => seenLater.push(info)) + expect(seenLater).toEqual([]) + + // once listeners exist, clicks are delivered live, not buffered + fireClick({ result: { url: 'https://peanut.me/rewards' }, notification: { additionalData: {} } }) + expect(seen).toHaveLength(2) + expect(seenLater).toHaveLength(1) + + off() + offLater() + }) +}) diff --git a/src/services/onesignal/native.adapter.ts b/src/services/onesignal/native.adapter.ts index de867004aa..fa6eb93140 100644 --- a/src/services/onesignal/native.adapter.ts +++ b/src/services/onesignal/native.adapter.ts @@ -14,6 +14,15 @@ let initPromise: Promise | null = null const permissionListeners = new Set<(state: NotificationPermissionState) => void>() const subscriptionListeners = new Set<(optedIn: boolean) => void>() const clickListeners = new Set<(info: NotificationClickInfo) => void>() +/** + * Cold-start tap buffer. Capacitor retains the click event only until the first + * JS listener attaches — which happens inside init() (attachUnderlyingListeners), + * driven by useNotifications. useNativePlugins registers its routing callback on + * a separate async path, so if init() wins that race the retained event would be + * consumed with an empty listener set and the tap silently dropped. Hold the + * last unconsumed click here and replay it to the next listener that registers. + */ +let pendingClick: NotificationClickInfo | null = null let underlyingListenersAttached = false function attachUnderlyingListeners() { @@ -34,6 +43,10 @@ function attachUnderlyingListeners() { deepLink: event?.result?.url ?? event?.notification?.launchURL, additionalData: (event?.notification?.additionalData ?? {}) as Record, } + if (clickListeners.size === 0) { + pendingClick = info + return + } clickListeners.forEach((cb) => cb(info)) }) } @@ -90,6 +103,11 @@ export const nativeOneSignalAdapter: OneSignalAdapter = { onNotificationClick(listener) { clickListeners.add(listener) + if (pendingClick) { + const buffered = pendingClick + pendingClick = null + listener(buffered) + } return () => clickListeners.delete(listener) }, } From c9a0d54dafb6d685783eb7cc17985e5878edbfe6 Mon Sep 17 00:00:00 2001 From: innolove-dev Date: Wed, 22 Jul 2026 15:54:09 +0100 Subject: [PATCH 35/36] fix(notifications): surface native OneSignal failures in Sentry Native OneSignal failure paths only console.warn'd, so zero events tagged environment:native ever reached Sentry while the same failures on web are visible via captureConsole. Capture the swallowed classes, each once per session at warning level, tagged feature:onesignal: - external_id login permanently disabled after an identity verification error (silently unlinks the device, pushes target 0 recipients) - native app listener init failure (push-tap deep links never route) - missing NEXT_PUBLIC_ONESIGNAL_APP_ID at adapter init - tag the existing onesignal_init capture with feature:onesignal --- src/hooks/useNativePlugins.ts | 12 ++++++++++++ src/hooks/useNotifications.ts | 11 +++++++++-- src/services/onesignal/native.adapter.ts | 6 ++++++ 3 files changed, 27 insertions(+), 2 deletions(-) diff --git a/src/hooks/useNativePlugins.ts b/src/hooks/useNativePlugins.ts index 557c94f92d..ecff0be3a0 100644 --- a/src/hooks/useNativePlugins.ts +++ b/src/hooks/useNativePlugins.ts @@ -2,6 +2,7 @@ import { useEffect } from 'react' import { useRouter } from 'next/navigation' +import { captureMessage } from '@sentry/nextjs' import { isCapacitor, getPlatform } from '@/utils/capacitor' import { deepLinkToNativePath } from '@/utils/native-routes' import { sanitizeRedirectURL } from '@/utils/general.utils' @@ -12,6 +13,8 @@ import { sanitizeRedirectURL } from '@/utils/general.utils' * plugins are loaded via dynamic import with webpackIgnore since they only * exist in native builds (not on vercel/web ci). */ +let appListenersFailureCaptured = false + export function useNativePlugins() { const router = useRouter() @@ -48,6 +51,15 @@ export function useNativePlugins() { cleanups.push(() => urlListener.remove()) } catch (e) { console.warn('failed to init app listeners:', e) + // without these listeners push-tap deep links never route, so surface the failure + if (!appListenersFailureCaptured) { + appListenersFailureCaptured = true + captureMessage('failed to init native app listeners', { + level: 'warning', + tags: { feature: 'onesignal', source: 'native_app_listeners' }, + extra: { error: e instanceof Error ? e.message : String(e) }, + }) + } } try { diff --git a/src/hooks/useNotifications.ts b/src/hooks/useNotifications.ts index d9c75b3b00..16594670d2 100644 --- a/src/hooks/useNotifications.ts +++ b/src/hooks/useNotifications.ts @@ -1,7 +1,7 @@ 'use client' import { useEffect, useSyncExternalStore } from 'react' -import { captureException } from '@sentry/nextjs' +import { captureException, captureMessage } from '@sentry/nextjs' import { getOneSignalAdapter, type NotificationPermissionState } from '@/services/onesignal' import { getUserPreferences, updateUserPreferences } from '@/utils/general.utils' import { isDemoMode } from '@/utils/demo' @@ -62,8 +62,15 @@ function handleLoginError(err: unknown) { const msg = err instanceof Error ? err.message : String(err ?? '') // disable login on identity verification errors if (msg.toLowerCase().includes('identity') || msg.toLowerCase().includes('verify')) { + if (disableExternalIdLogin) return disableExternalIdLogin = true console.warn('OneSignal external_id login disabled due to identity verification error') + // silently disabling login unlinks the device from the user, so pushes target no one + captureMessage('OneSignal external_id login disabled after identity verification error', { + level: 'warning', + tags: { feature: 'onesignal', onesignal: 'login-disabled' }, + extra: { error: msg }, + }) } } @@ -211,7 +218,7 @@ async function ensureInitialized() { } catch (e) { // Surface Brave/Shields SDK-block failures; previously silent. console.warn('OneSignal init failed', e) - captureException(e, { tags: { source: 'onesignal_init' } }) + captureException(e, { level: 'warning', tags: { feature: 'onesignal', source: 'onesignal_init' } }) } } diff --git a/src/services/onesignal/native.adapter.ts b/src/services/onesignal/native.adapter.ts index de867004aa..d1526cb015 100644 --- a/src/services/onesignal/native.adapter.ts +++ b/src/services/onesignal/native.adapter.ts @@ -1,4 +1,5 @@ import OneSignal from '@onesignal/capacitor-plugin' +import { captureMessage } from '@sentry/nextjs' import type { NotificationClickEvent, PushSubscriptionChangedState } from '@onesignal/capacitor-plugin' import type { NotificationClickInfo, NotificationPermissionState, OneSignalAdapter } from './types' @@ -44,6 +45,11 @@ export const nativeOneSignalAdapter: OneSignalAdapter = { initPromise = (async () => { const appId = process.env.NEXT_PUBLIC_ONESIGNAL_APP_ID if (!appId) { + // captured here too so a swallowed init() rejection can't hide a broken build config + captureMessage('OneSignal init failed: NEXT_PUBLIC_ONESIGNAL_APP_ID is missing', { + level: 'warning', + tags: { feature: 'onesignal', onesignal: 'missing-app-id' }, + }) throw new Error('OneSignal configuration missing: NEXT_PUBLIC_ONESIGNAL_APP_ID is required') } await OneSignal.initialize(appId) From 2e241a37dfd00673065f98566ea12af3814fe7e7 Mon Sep 17 00:00:00 2001 From: innolove-dev Date: Wed, 22 Jul 2026 22:42:38 +0100 Subject: [PATCH 36/36] merge: localization + eslint cleanup (#2447) into mobile-release MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brings the en / es-419 / pt-BR localization and the ESLint cleanup into the release branch for the next mobile build. ESLint errors on this branch: 450 -> 0 (CI's `eslint .`; 62 warnings remain, all pre-existing exhaustive-deps). Nine conflicts: - cancel-deposit copy: kept mobile-release's flat keys over #2447's ICU `select` restructure. Its component side reads `pendingCancel.noun`, but the field here is `kind` — taking it would have silently shown deposit copy for requests. Pure refactor of the same five strings, so nothing is lost. `cancelConfirm.*` and the unused `kyc.wrapper.*` keys are dropped; verified the latter are unreferenced on #2447 too. - AddMoneyBankDetails: took #2447's sender/recipient name-match checklist (real functionality mobile-release lacked). - SumsubKycWrapper test: took #2447's imports — its body needs `act` and the options passthrough. - three other test files: kept mobile-release's imports; their merged bodies already declare an IntlWrapper, so #2447's side would have duplicated the declaration. Localized the strings main shipped untranslated, which the jsx-no-literals guard from #2447 caught the moment the two lines met: - AppLock (native lock screen): title, prompt, failed prompt, unlock and log-out. The two prompts sit inside expression containers so the rule could not see them — translated anyway, or the screen ships half English. - ApplicationStatusScreen: 'Upload proof of address'. New appLock namespace + card.uploadProofOfAddress across all three locales. Full suite green: 164 suites, 2148 passed. Typecheck clean. Prettier clean on every tracked file. --- eslint.config.js | 38 ++++- package.json | 1 + pnpm-lock.yaml | 8 + scripts/native-build.js | 2 +- .../add-money/[country]/bank/page.tsx | 4 +- .../__tests__/add-money-states.test.tsx | 2 +- src/app/(mobile-ui)/history/page.tsx | 20 ++- src/app/(mobile-ui)/notifications/page.tsx | 2 +- src/app/(mobile-ui)/profile/page.tsx | 2 +- .../qr-pay/__tests__/qr-pay-states.test.tsx | 12 +- src/app/(mobile-ui)/qr-pay/page.tsx | 24 ++- src/app/(mobile-ui)/qr/[code]/page.tsx | 2 +- .../(mobile-ui)/qr/[code]/success/page.tsx | 4 +- src/app/(mobile-ui)/recover-funds/page.tsx | 4 +- src/app/(mobile-ui)/rewards/invites/page.tsx | 2 +- src/app/(mobile-ui)/rewards/page.tsx | 6 +- .../withdraw/[country]/bank/page.tsx | 12 +- .../__tests__/withdraw-states.test.tsx | 9 -- src/app/(mobile-ui)/withdraw/crypto/page.tsx | 6 +- src/app/(mobile-ui)/withdraw/page.tsx | 2 +- src/app/(setup)/layout.tsx | 2 +- src/app/[...recipient]/page.tsx | 3 +- src/app/[locale]/(marketing)/team/page.tsx | 3 +- src/app/actions/__tests__/currency.test.ts | 2 +- src/app/actions/bridge/get-customer.ts | 4 +- src/app/actions/card.ts | 8 +- src/app/actions/currency.ts | 2 +- src/app/actions/exchange-rate.ts | 2 +- src/app/actions/external-accounts.ts | 2 +- src/app/actions/onramp-quote.ts | 2 +- src/app/actions/users.ts | 36 +++-- src/app/api/health/justaname/route.ts | 8 +- src/app/api/health/route.ts | 35 +++- src/app/api/health/rpc/route.ts | 21 ++- src/app/api/health/zerodev/route.ts | 15 +- src/app/api/og/route.tsx | 2 +- src/app/api/recent-transactions/route.ts | 2 +- src/app/careers/page.tsx | 2 +- src/app/dev/loading-words/page.tsx | 3 +- src/app/kyc/success/page.tsx | 2 +- src/app/lp/card/CardLandingPage.tsx | 3 +- src/app/quests/[questId]/page.tsx | 2 +- src/app/quests/components/QuestsHero.tsx | 2 +- src/app/quests/explore/page.tsx | 10 +- .../components/AddMoneyBankDetails.tsx | 6 + .../AddMoney/components/MantecaAddMoney.tsx | 4 +- .../__tests__/MantecaPixQrDeposit.test.tsx | 2 +- .../useMantecaDepositPolling.test.tsx | 2 +- .../AddWithdraw/AddWithdrawCountriesList.tsx | 6 +- .../AddWithdraw/DynamicBankAccountForm.tsx | 35 ++-- .../__tests__/AddWithdrawRouterView.test.tsx | 10 +- src/components/Badges/badge.types.ts | 3 +- .../Card/ApplicationStatusScreen.tsx | 2 +- src/components/Card/CardFace.tsx | 80 ++++++---- src/components/Card/CardLimitEditModal.tsx | 29 ++++ .../ApplicationStatusScreen.test.tsx | 1 - .../Card/share-asset/RejectionAssetD3.tsx | 1 - .../Card/share-asset/ShareAssetD3.tsx | 1 - src/components/Claim/Claim.tsx | 2 +- src/components/Claim/Link/Initial.view.tsx | 47 +++++- .../Claim/Link/MantecaFlowManager.tsx | 7 +- .../Claim/Link/Onchain/Confirm.view.tsx | 3 +- .../Claim/Link/SendLinkActionList.tsx | 4 +- .../__tests__/SendLinkActionList.test.tsx | 2 +- .../Claim/Link/views/BankFlowManager.view.tsx | 10 +- .../Link/views/Confirm.bank-claim.view.tsx | 2 +- .../Claim/__tests__/claim-states.test.tsx | 9 -- src/components/Claim/useClaimLink.tsx | 16 +- src/components/Common/SavedAccountsView.tsx | 2 +- src/components/Create/useCreateLink.tsx | 2 +- src/components/ExchangeRate/index.tsx | 2 +- src/components/Global/AmountInput/index.tsx | 56 +++++-- src/components/Global/AppLock/index.tsx | 12 +- src/components/Global/Banner/index.tsx | 2 +- .../Global/ConfirmInviteModal/index.tsx | 3 +- .../Global/CreateAccountButton/index.tsx | 3 +- .../__tests__/GeneralRecipientInput.test.tsx | 2 +- src/components/Global/Layout/index.tsx | 11 +- src/components/Global/LogoutButton/index.tsx | 2 +- .../Global/MarqueeWrapper/index.tsx | 11 +- src/components/Global/Modal/index.tsx | 2 +- .../Global/NoMoreJailModal/index.tsx | 3 +- .../Global/PeanutActionCard/index.tsx | 2 +- .../Global/PeanutLoading/CyclingLoading.tsx | 3 +- src/components/Global/PeanutLoading/index.tsx | 5 +- .../Global/PostSignupActionManager/index.tsx | 4 +- src/components/Global/ProgressBar/index.tsx | 2 +- src/components/Global/QRCodeWrapper/index.tsx | 2 +- .../Global/QRScanner/useQRScanner.ts | 9 +- .../Global/ScreenOrientationLocker.tsx | 8 +- src/components/Global/ShareButton/index.tsx | 7 +- src/components/Global/SoundPlayer.tsx | 7 +- .../Components/NetworkListView.tsx | 11 +- .../Components/TokenListItem.tsx | 2 +- .../TokenSelector/TokenSelector.consts.ts | 5 +- .../Global/TokenSelector/TokenSelector.tsx | 4 +- .../Global/WalletNavigation/index.tsx | 2 +- src/components/Home/HomeHistory.tsx | 27 ++-- src/components/Home/PerkClaimModal.tsx | 4 +- src/components/Invites/InvitesPage.tsx | 3 +- src/components/Jobs/index.tsx | 3 +- src/components/Kyc/InitiateKycModal.tsx | 24 ++- .../Kyc/KycVerificationInProgressModal.tsx | 2 +- src/components/Kyc/SumsubKycWrapper.tsx | 61 ++++++- .../Kyc/__tests__/SumsubKycWrapper.test.tsx | 84 +++++++++- src/components/Kyc/states/KycCompleted.tsx | 2 +- src/components/LandingPage/CardPioneers.tsx | 2 +- src/components/LandingPage/CloudsCss.tsx | 6 +- src/components/LandingPage/Footer.tsx | 5 +- src/components/LandingPage/Manteca.tsx | 6 +- src/components/LandingPage/RegulatedRails.tsx | 26 ++- src/components/LandingPage/TweetCarousel.tsx | 13 +- src/components/LandingPage/faq.tsx | 3 +- src/components/LandingPage/hero.tsx | 14 +- src/components/LandingPage/marquee.tsx | 2 +- src/components/LandingPage/noFees.tsx | 2 +- .../LandingPage/securityBuiltIn.tsx | 6 +- src/components/LandingPage/sendInSeconds.tsx | 4 +- src/components/LandingPage/yourMoney.tsx | 2 +- src/components/Marketing/DestinationGrid.tsx | 3 +- src/components/Marketing/MarketingHero.tsx | 7 +- .../Marketing/mdx/ExchangeWidget.tsx | 11 +- src/components/Marketing/mdx/FAQ.tsx | 2 +- src/components/Marketing/mdx/Hero.tsx | 2 +- src/components/Marketing/mdx/ProseStars.tsx | 5 +- src/components/Marketing/mdx/Stars.tsx | 5 +- src/components/Offramp/Offramp.consts.ts | 8 +- .../Profile/components/PublicProfile.tsx | 4 +- .../Request/__tests__/request-states.test.tsx | 18 +-- .../views/Initial.direct.request.view.tsx | 9 +- .../link/views/Create.request.link.view.tsx | 2 +- .../Send/link/LinkSendFlowManager.tsx | 4 +- .../link/views/Initial.link.send.view.tsx | 2 +- .../link/views/Success.link.send.view.tsx | 2 +- src/components/Send/views/SendRouter.view.tsx | 3 +- src/components/Setup/Setup.utils.ts | 2 +- src/components/Setup/Views/JoinWaitlist.tsx | 9 +- src/components/Setup/Views/Landing.tsx | 9 +- .../Setup/Views/SignTestTransaction.tsx | 2 +- .../TransactionDetails/PerkIcon.tsx | 2 +- .../TransactionDetailsReceipt.tsx | 2 +- .../__tests__/TransactionCard.test.tsx | 1 - .../Withdraw/views/Confirm.withdraw.view.tsx | 2 +- .../Withdraw/views/Initial.withdraw.view.tsx | 2 +- src/components/index.ts | 4 - src/components/og/PaymentCardOG.tsx | 2 +- src/components/og/ReceiptCardOG.tsx | 2 +- src/config/index.ts | 2 - src/config/peanut.config.tsx | 16 +- src/constants/__tests__/chainRegistry.test.ts | 2 +- src/constants/__tests__/nonEvmLeak.test.ts | 2 +- src/constants/actionlist.consts.ts | 6 +- src/constants/analytics.consts.ts | 12 ++ src/constants/kyc.consts.ts | 2 +- src/constants/routes.ts | 2 +- src/constants/zerodev.consts.ts | 5 +- src/context/ClaimBankFlowContext.tsx | 2 +- src/context/RequestFulfillmentFlowContext.tsx | 2 +- src/context/WithdrawFlowContext.tsx | 2 +- src/context/index.ts | 5 - src/context/kernelClient.context.tsx | 6 +- src/context/tokenSelector.context.tsx | 2 +- .../limits/hooks/useLimitsValidation.ts | 2 +- src/features/limits/utils.ts | 2 +- src/features/limits/views/LimitsPageView.tsx | 2 +- .../direct-send/DirectSendPageWrapper.tsx | 2 +- .../useSemanticRequestFlow.ts | 2 +- .../views/SemanticRequestInputView.tsx | 2 +- .../shared/components/PaymentSuccessView.tsx | 3 - .../shared/components/SendWithPeanutCta.tsx | 3 +- .../__tests__/useGetExchangeRate.test.ts | 2 +- src/hooks/__tests__/useSumsubKycFlow.test.ts | 3 + .../__tests__/useUserAutoRefresh.test.ts | 2 +- src/hooks/query/user.ts | 2 +- src/hooks/useAccountSetup.ts | 2 +- src/hooks/useAccountSetupRedirect.ts | 2 +- src/hooks/useContacts.ts | 2 +- src/hooks/useCreateOnramp.ts | 5 +- src/hooks/useCrispUserData.ts | 2 +- src/hooks/useFeatureFlag.ts | 4 +- src/hooks/useGeoLocation.ts | 4 +- src/hooks/useGetBrowserType.ts | 3 +- src/hooks/useGetExchangeRate.tsx | 2 +- src/hooks/useHomeCarouselCTAs.tsx | 2 +- src/hooks/useLimits.ts | 2 +- src/hooks/useOnrampQuote.ts | 2 +- src/hooks/usePWAStatus.ts | 2 +- src/hooks/useSetupFlow.ts | 4 +- src/hooks/useSumsubKycFlow.ts | 15 +- src/hooks/useTokenPrice.ts | 2 +- src/hooks/useTranslationMutationHandler.ts | 5 +- src/hooks/useUserAutoRefresh.ts | 2 +- src/hooks/useZeroDev.ts | 2 +- .../useReturnExcessCollateral.test.tsx | 119 ++++++++++++++ .../wallet/__tests__/useSendMoney.test.tsx | 4 +- .../__tests__/useSignSpendBundle.test.tsx | 150 ++++++++++++++++++ src/hooks/wallet/useGrantSessionKey.ts | 8 +- src/hooks/wallet/useReturnExcessCollateral.ts | 68 ++++++++ src/hooks/wallet/useSendMoney.ts | 2 +- src/hooks/wallet/useSignSpendBundle.ts | 60 +++++-- src/i18n/app/messages/en.json | 24 ++- src/i18n/app/messages/es-419.json | 24 ++- src/i18n/app/messages/pt-BR.json | 24 ++- src/interfaces/index.ts | 2 - src/interfaces/interfaces.ts | 21 --- src/lib/validation/recipient.ts | 2 +- src/redux/slices/user-slice.ts | 2 +- src/redux/types/user.types.ts | 2 +- src/services/manteca.ts | 2 +- src/services/quests.ts | 8 +- src/services/sendLinks.ts | 2 +- src/services/tokens-price.ts | 2 +- src/services/users.ts | 2 +- src/services/websocket.ts | 8 +- src/types/global.d.ts | 9 ++ src/types/react-force-graph.d.ts | 49 ------ src/types/sumsub-cordova.d.ts | 2 +- src/utils/__tests__/auth-token.test.ts | 2 +- src/utils/__tests__/balance.utils.test.ts | 39 +++++ src/utils/__tests__/capacitor.test.ts | 22 +-- src/utils/__tests__/demo-api.test.ts | 6 +- src/utils/__tests__/demo-balance.test.ts | 1 - src/utils/__tests__/general.utils.test.ts | 2 +- src/utils/__tests__/railGate.utils.test.ts | 2 +- src/utils/__tests__/url.utils.test.ts | 1 - src/utils/balance.utils.ts | 27 ++++ src/utils/bridge.utils.ts | 2 +- src/utils/capability-gate.ts | 10 ++ src/utils/capacitor.ts | 6 +- src/utils/crisp.ts | 10 +- src/utils/demo-api.ts | 11 +- src/utils/general.utils.ts | 9 +- src/utils/kyc-grouping.utils.ts | 2 +- src/utils/no-cache.ts | 6 +- src/utils/passkeyDebug.ts | 2 +- src/utils/railGate.utils.ts | 2 +- src/utils/sentry.utils.ts | 8 +- src/utils/token.utils.ts | 10 +- 238 files changed, 1549 insertions(+), 618 deletions(-) delete mode 100644 src/components/index.ts delete mode 100644 src/config/index.ts delete mode 100644 src/context/index.ts create mode 100644 src/hooks/wallet/__tests__/useReturnExcessCollateral.test.tsx create mode 100644 src/hooks/wallet/__tests__/useSignSpendBundle.test.tsx create mode 100644 src/hooks/wallet/useReturnExcessCollateral.ts delete mode 100644 src/interfaces/index.ts delete mode 100644 src/types/react-force-graph.d.ts diff --git a/eslint.config.js b/eslint.config.js index b807adcf84..4a7aa6fb04 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -85,6 +85,8 @@ module.exports = [ 'react/prop-types': 'off', // Allow unescaped quotes — too noisy and prettier handles spacing 'react/no-unescaped-entities': 'off', + // `jsx`/`global` are styled-jsx's