diff --git a/public/badges/skip_pass.svg b/public/badges/skip_pass.svg new file mode 100644 index 000000000..1aadc08b8 --- /dev/null +++ b/public/badges/skip_pass.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/scripts/verify-content.ts b/scripts/verify-content.ts index 734bda315..7d9ed1e85 100644 --- a/scripts/verify-content.ts +++ b/scripts/verify-content.ts @@ -83,6 +83,17 @@ function listDirs(dir: string): string[] { .map((d) => d.name) } +/** + * Receive-money-from pages render only for corridor origins that actually have + * a receive-from article. Mirrors RECEIVE_SOURCES in src/data/seo/corridors.ts. + * Without this gate, both the route index and the sitemap "expected URLs" would + * agree with each other on a slug that 404s at runtime (e.g. colombia, mexico). + */ +function gateReceiveSources(corridors: Array<{ from: string; to: string }>): string[] { + const origins = [...new Set(corridors.map((c) => c.from))] + return origins.filter((slug) => fs.existsSync(path.join(CONTENT_DIR, 'receive-from', slug, 'en.md'))) +} + function getAllMdFiles(dir: string): string[] { const results: string[] = [] if (!fs.existsSync(dir)) return results @@ -192,7 +203,7 @@ function discoverRoutes(): Set { corridors.push({ to: dest, from: origin }) } } - const receiveSources = [...new Set(corridors.map((c) => c.from))] + const receiveSources = gateReceiveSources(corridors) // Check which routes actually have page.tsx files const hasRoute = (routePath: string) => { @@ -704,7 +715,7 @@ function expectedSitemapUrls(): string[] { const fromDir = path.join(CONTENT_DIR, 'send-to', dest, 'from') for (const origin of listDirs(fromDir)) corridors.push({ from: origin, to: dest }) } - const receiveSources = [...new Set(corridors.map((c) => c.from))] + const receiveSources = gateReceiveSources(corridors) for (const locale of SUPPORTED_LOCALES) { for (const slug of countrySlugs) { diff --git a/sentry.utils.ts b/sentry.utils.ts index 31b89dfea..8daf91052 100644 --- a/sentry.utils.ts +++ b/sentry.utils.ts @@ -82,13 +82,228 @@ export function shouldIgnoreError(event: ErrorEvent): boolean { } /** - * Clean sensitive headers from events + * Defense-in-depth: even when call-site code (fetchWithSentry, etc) has + * already scrubbed payloads, walk every Sentry event one more time and + * redact known sensitive headers, fields, and breadcrumb data before it + * leaves the browser. Catches the long tail of errors that come from + * places we don't control (third-party SDKs, error boundaries, console + * spam) and might carry PII / card data / passwords in `extra`. + * + * What stays unredacted: userId, username, email, inviteCode — identity + * fields already shared with PostHog and intentionally queryable in + * Sentry too. + */ +const SENSITIVE_HEADERS = [ + 'authorization', + 'cookie', + 'set-cookie', + 'x-auth-token', + 'api-key', + 'x-api-key', + 'apikey', + 'md-api-key', + 'x-app-token', + 'x-app-access-sig', + 'x-app-access-ts', +] + +/** + * IMPORTANT: EXACT-MATCH set. We deliberately do NOT substring-match + * because Peanut has first-class onchain addresses everywhere (walletAddress, + * recipientAddress, tokenAddress, sdaAddress, depositAddress, …) and those + * are public chain data that must stay visible. Substring on `address` + * would clobber every one. Same for `pin` / `token` / `seed` — share names + * with non-sensitive concepts. + */ +const SENSITIVE_KEYS = new Set([ + // Secrets + 'password', + 'pwd', + 'passphrase', + 'secret', + 'secretkey', + 'apikey', + 'apitoken', + 'bearer', + 'authtoken', + 'jwt', + 'token', + 'sessiontoken', + 'refreshtoken', + 'accesstoken', + 'idtoken', + 'privatekey', + 'mnemonic', + 'seed', + 'seedphrase', + 'recoveryphrase', + // Card data + 'pan', + 'cardnumber', + 'cvv', + 'cvc', + 'securitycode', + 'cardpin', + 'pin', + 'cardholdername', + 'expirydate', + 'expirymonth', + 'expiryyear', + 'expmonth', + 'expyear', + // Government IDs (English + Bridge long-form) + 'ssn', + 'socialsecurity', + 'socialsecuritynumber', + 'taxid', + 'taxidentificationnumber', + 'tin', + 'dni', + 'cuit', + 'cuil', + 'rfc', + 'curp', + 'nif', + 'governmentid', + 'governmentidnumber', + 'documentnumber', + 'passport', + 'passportnumber', + 'driverslicense', + 'licensenumber', + 'idnumber', + 'nationalid', + 'nationalidnumber', + // Manteca (Spanish) + 'documento', + 'numerodocumento', + 'numerodedocumento', + // Bank accounts + 'iban', + 'swift', + 'bic', + 'sortcode', + 'routingnumber', + 'accountnumber', + 'bankaccountnumber', + 'cbu', + 'cvu', + 'clabe', + // PII — names (English + Manteca Spanish) + 'firstname', + 'lastname', + 'fullname', + 'givenname', + 'familyname', + 'surname', + 'middlename', + 'mothername', + 'mothersmaidenname', + 'maidenname', + 'customerfirstname', + 'customerlastname', + 'nombre', + 'apellido', + // PII — address. NOTE: `address` alone is NOT here — onchain addresses + // (walletAddress, recipientAddress, etc.) must stay visible for + // debugging onchain flows. + 'streetaddress', + 'street1', + 'street2', + 'street3', + 'streetline1', + 'streetline2', + 'streetline3', + 'addressline1', + 'addressline2', + 'addressline3', + 'billingaddress', + 'homeaddress', + 'mailingaddress', + 'residentialaddress', + 'permanentaddress', + 'direccion', + 'domicilio', + 'postalcode', + 'zipcode', + 'zip', + 'postcode', + 'dob', + 'dateofbirth', + 'birthdate', + 'birthday', + 'phonenumber', + 'mobilenumber', + 'telephone', + 'telefono', + // 2FA + 'otp', + 'verificationcode', + 'totpsecret', + 'twofactor', + 'twofactorsecret', +]) + +function isSensitiveKey(key: string): boolean { + return SENSITIVE_KEYS.has(key.toLowerCase().replace(/[_-]/g, '')) +} + +function scrubObject(value: unknown, depth = 0): unknown { + if (depth > 10) return '[REDACTED: max depth]' + if (value === null || value === undefined) return value + if (typeof value !== 'object') return value + if (Array.isArray(value)) return value.map((item) => scrubObject(item, depth + 1)) + // Prototype-pollution defense — see src/utils/sentry.utils.ts for the + // full rationale. Object.create(null) + Object.defineProperty + explicit + // dangerous-key skip. The defineProperty form is what CodeQL recognises + // as a sanitizer; direct `out[key] = …` triggers the alert even when + // keys are validated at runtime. + const out: Record = Object.create(null) + for (const [key, val] of Object.entries(value as Record)) { + if (key === '__proto__' || key === 'constructor' || key === 'prototype') continue + Object.defineProperty(out, key, { + value: isSensitiveKey(key) ? '[REDACTED]' : scrubObject(val, depth + 1), + writable: true, + enumerable: true, + configurable: true, + }) + } + return out +} + +/** + * Clean sensitive headers, extras, request data, and breadcrumbs from events + * before they leave the browser. */ export function cleanSensitiveHeaders(event: ErrorEvent): void { if (event.request?.headers) { - delete event.request.headers['Authorization'] - delete event.request.headers['api-key'] - delete event.request.headers['cookie'] + for (const key of Object.keys(event.request.headers)) { + if (SENSITIVE_HEADERS.includes(key.toLowerCase())) { + event.request.headers[key] = '[REDACTED]' + } + } + } + if (event.request?.data) { + event.request.data = scrubObject(event.request.data) + } + if (event.extra) { + event.extra = scrubObject(event.extra) as Record + } + if (event.contexts) { + for (const [key, value] of Object.entries(event.contexts)) { + if (key === 'trace') continue + ;(event.contexts as Record)[key] = scrubObject(value) + } + } + if (event.breadcrumbs) { + event.breadcrumbs = event.breadcrumbs.map((crumb) => ({ + ...crumb, + data: crumb.data + ? (Object.fromEntries( + Object.entries(crumb.data).map(([k, v]) => [k, isSensitiveKey(k) ? '[REDACTED]' : scrubObject(v)]) + ) as Record) + : crumb.data, + })) } } diff --git a/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx b/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx index 5b8677652..3d4c2ff78 100644 --- a/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx +++ b/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx @@ -38,6 +38,7 @@ import posthog from 'posthog-js' import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' import { addMoneyCountryUrl } from '@/utils/native-routes' import { useSafeBack } from '@/hooks/useSafeBack' +import { getRegionIntent } from '@/utils/regions.utils' // Step type for URL state type BridgeBankStep = 'inputAmount' | 'showDetails' @@ -71,7 +72,8 @@ export default function OnrampBankPage() { // inline sumsub kyc flow for bridge bank onramp // regionIntent is NOT passed here to avoid creating a backend record on mount. - // intent is passed at call time: handleInitiateKyc('STANDARD') + // intent is passed at call time, derived from the destination country + // (e.g. /add-money/usa → NA → bridge-requirements). const sumsubFlow = useMultiPhaseKycFlow({ onKycSuccess: () => { setUrlState({ step: 'inputAmount' }) @@ -418,7 +420,7 @@ export default function OnrampBankPage() { await sumsubFlow.handleSelfHealResubmit('BRIDGE') } else { await sumsubFlow.handleInitiateKyc( - 'STANDARD', + getRegionIntent(selectedCountry?.region ?? 'rest-of-the-world'), undefined, gate.kind === 'needs-enrollment' || undefined ) diff --git a/src/app/(mobile-ui)/add-money/__tests__/add-money-states.test.tsx b/src/app/(mobile-ui)/add-money/__tests__/add-money-states.test.tsx index 6f6cb3bfd..3fc6ac842 100644 --- a/src/app/(mobile-ui)/add-money/__tests__/add-money-states.test.tsx +++ b/src/app/(mobile-ui)/add-money/__tests__/add-money-states.test.tsx @@ -256,6 +256,7 @@ jest.mock('@/constants/countryCurrencyMapping', () => ({ ], isNonEuroSepaCountry: jest.fn(() => false), isUKCountry: jest.fn(() => false), + getFlagUrl: jest.fn((iso2: string) => `/flags/${iso2}.svg`), })) // Rhino consts @@ -681,6 +682,7 @@ jest.mock('@/components/AddMoney/consts', () => ({ { type: 'country', id: 'XX', path: 'unknown', currency: 'USD', iso3: 'XXX' }, ], ALL_COUNTRIES_ALPHA3_TO_ALPHA2: { ARG: 'AR', BRA: 'BR', USA: 'US', DEU: 'DE', MEX: 'MX', GBR: 'GB' }, + BRIDGE_ALPHA3_TO_ALPHA2: { USA: 'US', DEU: 'DE', MEX: 'MX', GBR: 'GB' }, })) jest.mock('@/components/TransactionDetails/transactionTransformer', () => ({})) diff --git a/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx b/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx index 59fd34bce..568ba377b 100644 --- a/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx +++ b/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx @@ -38,7 +38,7 @@ import { getKycModalVariant, getGateUserMessage } from '@/utils/capability-gate' import { useModalsContext } from '@/context/ModalsContext' import ExchangeRate from '@/components/ExchangeRate' import countryCurrencyMappings, { isNonEuroSepaCountry } from '@/constants/countryCurrencyMapping' -import { isBridgeSupportedCountry } from '@/utils/regions.utils' +import { isBridgeSupportedCountry, getRegionIntent } from '@/utils/regions.utils' import { PointsAction } from '@/services/services.types' import { usePointsCalculation } from '@/hooks/usePointsCalculation' import { parseUnits } from 'viem' @@ -533,7 +533,7 @@ export default function WithdrawBankPage() { await sumsubFlow.handleSelfHealResubmit('BRIDGE') } else { await sumsubFlow.handleInitiateKyc( - 'STANDARD', + getRegionIntent(getCountryFromPath(country)?.region ?? 'rest-of-the-world'), undefined, gate.kind === 'needs-enrollment' || undefined ) diff --git a/src/app/[locale]/(marketing)/card-privacy/page.tsx b/src/app/[locale]/(marketing)/card-privacy/page.tsx new file mode 100644 index 000000000..0920f9e05 --- /dev/null +++ b/src/app/[locale]/(marketing)/card-privacy/page.tsx @@ -0,0 +1,83 @@ +import { notFound } from 'next/navigation' +import { type Metadata } from 'next' +import { generateMetadata as metadataHelper } from '@/app/metadata' +import { SUPPORTED_LOCALES, getAlternates, isValidLocale } from '@/i18n/config' +import { getTranslations } from '@/i18n' +import { ContentPage } from '@/components/Marketing/ContentPage' +import { readPageContentLocalized } from '@/lib/content' +import { renderContent } from '@/lib/mdx' + +interface PageProps { + params: Promise<{ locale: string }> +} + +interface LegalFrontmatter { + title: string + description: string + slug: string + published?: boolean + last_updated?: string +} + +const SLUG = 'card-privacy' + +export async function generateStaticParams() { + return SUPPORTED_LOCALES.map((locale) => ({ locale })) +} +export const dynamicParams = false + +export async function generateMetadata({ params }: PageProps): Promise { + const { locale } = await params + if (!isValidLocale(locale)) return {} + + const mdxContent = readPageContentLocalized('legal', SLUG, locale) + if (!mdxContent || mdxContent.frontmatter.published === false) return {} + + return { + ...metadataHelper({ + title: mdxContent.frontmatter.title, + description: mdxContent.frontmatter.description, + canonical: `/${locale}/${SLUG}`, + }), + alternates: { + canonical: `/${locale}/${SLUG}`, + languages: getAlternates(SLUG), + }, + } +} + +export default async function CardPrivacyPage({ params }: PageProps) { + const { locale } = await params + if (!isValidLocale(locale)) notFound() + + const mdxSource = readPageContentLocalized('legal', SLUG, locale) + if (!mdxSource || mdxSource.frontmatter.published === false) notFound() + + const { content } = await renderContent(mdxSource.body) + const i18n = getTranslations(locale) + + const displayTitle = mdxSource.frontmatter.title.replace(/\s*\|\s*Peanut$/, '') + const url = `/${locale}/${SLUG}` + + return ( + + {content} + + ) +} diff --git a/src/app/[locale]/(marketing)/card-prohibited-activities/page.tsx b/src/app/[locale]/(marketing)/card-prohibited-activities/page.tsx new file mode 100644 index 000000000..cf7c83552 --- /dev/null +++ b/src/app/[locale]/(marketing)/card-prohibited-activities/page.tsx @@ -0,0 +1,83 @@ +import { notFound } from 'next/navigation' +import { type Metadata } from 'next' +import { generateMetadata as metadataHelper } from '@/app/metadata' +import { SUPPORTED_LOCALES, getAlternates, isValidLocale } from '@/i18n/config' +import { getTranslations } from '@/i18n' +import { ContentPage } from '@/components/Marketing/ContentPage' +import { readPageContentLocalized } from '@/lib/content' +import { renderContent } from '@/lib/mdx' + +interface PageProps { + params: Promise<{ locale: string }> +} + +interface LegalFrontmatter { + title: string + description: string + slug: string + published?: boolean + last_updated?: string +} + +const SLUG = 'card-prohibited-activities' + +export async function generateStaticParams() { + return SUPPORTED_LOCALES.map((locale) => ({ locale })) +} +export const dynamicParams = false + +export async function generateMetadata({ params }: PageProps): Promise { + const { locale } = await params + if (!isValidLocale(locale)) return {} + + const mdxContent = readPageContentLocalized('legal', SLUG, locale) + if (!mdxContent || mdxContent.frontmatter.published === false) return {} + + return { + ...metadataHelper({ + title: mdxContent.frontmatter.title, + description: mdxContent.frontmatter.description, + canonical: `/${locale}/${SLUG}`, + }), + alternates: { + canonical: `/${locale}/${SLUG}`, + languages: getAlternates(SLUG), + }, + } +} + +export default async function CardProhibitedActivitiesPage({ params }: PageProps) { + const { locale } = await params + if (!isValidLocale(locale)) notFound() + + const mdxSource = readPageContentLocalized('legal', SLUG, locale) + if (!mdxSource || mdxSource.frontmatter.published === false) notFound() + + const { content } = await renderContent(mdxSource.body) + const i18n = getTranslations(locale) + + const displayTitle = mdxSource.frontmatter.title.replace(/\s*\|\s*Peanut$/, '') + const url = `/${locale}/${SLUG}` + + return ( + + {content} + + ) +} diff --git a/src/app/[locale]/(marketing)/card-terms-international/page.tsx b/src/app/[locale]/(marketing)/card-terms-international/page.tsx new file mode 100644 index 000000000..87b3fcc9d --- /dev/null +++ b/src/app/[locale]/(marketing)/card-terms-international/page.tsx @@ -0,0 +1,83 @@ +import { notFound } from 'next/navigation' +import { type Metadata } from 'next' +import { generateMetadata as metadataHelper } from '@/app/metadata' +import { SUPPORTED_LOCALES, getAlternates, isValidLocale } from '@/i18n/config' +import { getTranslations } from '@/i18n' +import { ContentPage } from '@/components/Marketing/ContentPage' +import { readPageContentLocalized } from '@/lib/content' +import { renderContent } from '@/lib/mdx' + +interface PageProps { + params: Promise<{ locale: string }> +} + +interface LegalFrontmatter { + title: string + description: string + slug: string + published?: boolean + last_updated?: string +} + +const SLUG = 'card-terms-international' + +export async function generateStaticParams() { + return SUPPORTED_LOCALES.map((locale) => ({ locale })) +} +export const dynamicParams = false + +export async function generateMetadata({ params }: PageProps): Promise { + const { locale } = await params + if (!isValidLocale(locale)) return {} + + const mdxContent = readPageContentLocalized('legal', SLUG, locale) + if (!mdxContent || mdxContent.frontmatter.published === false) return {} + + return { + ...metadataHelper({ + title: mdxContent.frontmatter.title, + description: mdxContent.frontmatter.description, + canonical: `/${locale}/${SLUG}`, + }), + alternates: { + canonical: `/${locale}/${SLUG}`, + languages: getAlternates(SLUG), + }, + } +} + +export default async function CardTermsInternationalPage({ params }: PageProps) { + const { locale } = await params + if (!isValidLocale(locale)) notFound() + + const mdxSource = readPageContentLocalized('legal', SLUG, locale) + if (!mdxSource || mdxSource.frontmatter.published === false) notFound() + + const { content } = await renderContent(mdxSource.body) + const i18n = getTranslations(locale) + + const displayTitle = mdxSource.frontmatter.title.replace(/\s*\|\s*Peanut$/, '') + const url = `/${locale}/${SLUG}` + + return ( + + {content} + + ) +} diff --git a/src/app/[locale]/(marketing)/card-terms-us/page.tsx b/src/app/[locale]/(marketing)/card-terms-us/page.tsx new file mode 100644 index 000000000..6a8aeab3b --- /dev/null +++ b/src/app/[locale]/(marketing)/card-terms-us/page.tsx @@ -0,0 +1,83 @@ +import { notFound } from 'next/navigation' +import { type Metadata } from 'next' +import { generateMetadata as metadataHelper } from '@/app/metadata' +import { SUPPORTED_LOCALES, getAlternates, isValidLocale } from '@/i18n/config' +import { getTranslations } from '@/i18n' +import { ContentPage } from '@/components/Marketing/ContentPage' +import { readPageContentLocalized } from '@/lib/content' +import { renderContent } from '@/lib/mdx' + +interface PageProps { + params: Promise<{ locale: string }> +} + +interface LegalFrontmatter { + title: string + description: string + slug: string + published?: boolean + last_updated?: string +} + +const SLUG = 'card-terms-us' + +export async function generateStaticParams() { + return SUPPORTED_LOCALES.map((locale) => ({ locale })) +} +export const dynamicParams = false + +export async function generateMetadata({ params }: PageProps): Promise { + const { locale } = await params + if (!isValidLocale(locale)) return {} + + const mdxContent = readPageContentLocalized('legal', SLUG, locale) + if (!mdxContent || mdxContent.frontmatter.published === false) return {} + + return { + ...metadataHelper({ + title: mdxContent.frontmatter.title, + description: mdxContent.frontmatter.description, + canonical: `/${locale}/${SLUG}`, + }), + alternates: { + canonical: `/${locale}/${SLUG}`, + languages: getAlternates(SLUG), + }, + } +} + +export default async function CardTermsUsPage({ params }: PageProps) { + const { locale } = await params + if (!isValidLocale(locale)) notFound() + + const mdxSource = readPageContentLocalized('legal', SLUG, locale) + if (!mdxSource || mdxSource.frontmatter.published === false) notFound() + + const { content } = await renderContent(mdxSource.body) + const i18n = getTranslations(locale) + + const displayTitle = mdxSource.frontmatter.title.replace(/\s*\|\s*Peanut$/, '') + const url = `/${locale}/${SLUG}` + + return ( + + {content} + + ) +} diff --git a/src/app/[locale]/(marketing)/receive-money-from/[country]/page.tsx b/src/app/[locale]/(marketing)/receive-money-from/[country]/page.tsx index a1dbb2a0d..82da4c851 100644 --- a/src/app/[locale]/(marketing)/receive-money-from/[country]/page.tsx +++ b/src/app/[locale]/(marketing)/receive-money-from/[country]/page.tsx @@ -1,7 +1,7 @@ import { notFound } from 'next/navigation' import { type Metadata } from 'next' import { generateMetadata as metadataHelper } from '@/app/metadata' -import { CORRIDORS, getCountryName } from '@/data/seo' +import { RECEIVE_SOURCES, getCountryName } from '@/data/seo' import { SUPPORTED_LOCALES, getAlternates, isValidLocale } from '@/i18n/config' import type { Locale } from '@/i18n/types' import { getTranslations, t } from '@/i18n' @@ -13,21 +13,15 @@ interface PageProps { params: Promise<{ locale: string; country: string }> } -/** Unique sending countries */ -function getReceiveSources(): string[] { - return [...new Set(CORRIDORS.map((c) => c.from))] -} - export async function generateStaticParams() { - const sources = getReceiveSources() - return SUPPORTED_LOCALES.flatMap((locale) => sources.map((country) => ({ locale, country }))) + return SUPPORTED_LOCALES.flatMap((locale) => RECEIVE_SOURCES.map((country) => ({ locale, country }))) } export const dynamicParams = false export async function generateMetadata({ params }: PageProps): Promise { const { locale, country } = await params if (!isValidLocale(locale)) return {} - if (!getReceiveSources().includes(country)) return {} + if (!RECEIVE_SOURCES.includes(country)) return {} const mdxContent = readPageContentLocalized('receive-from', country, locale) if (!mdxContent || mdxContent.frontmatter.published === false) return {} @@ -51,7 +45,7 @@ export async function generateMetadata({ params }: PageProps): Promise export default async function ReceiveMoneyPage({ params }: PageProps) { const { locale, country } = await params if (!isValidLocale(locale)) notFound() - if (!getReceiveSources().includes(country)) notFound() + if (!RECEIVE_SOURCES.includes(country)) notFound() const mdxSource = readPageContentLocalized('receive-from', country, locale) if (!mdxSource || mdxSource.frontmatter.published === false) notFound() diff --git a/src/app/sitemap.ts b/src/app/sitemap.ts index e0f736d9d..83d954b1d 100644 --- a/src/app/sitemap.ts +++ b/src/app/sitemap.ts @@ -1,6 +1,14 @@ import { type MetadataRoute } from 'next' import { BASE_URL } from '@/constants/general.consts' -import { COUNTRIES_SEO, CORRIDORS, COMPETITORS, EXCHANGES, DEPOSIT_RAILS, PAYMENT_METHOD_SLUGS } from '@/data/seo' +import { + COUNTRIES_SEO, + CORRIDORS, + RECEIVE_SOURCES, + COMPETITORS, + EXCHANGES, + DEPOSIT_RAILS, + PAYMENT_METHOD_SLUGS, +} from '@/data/seo' import { SUPPORTED_LOCALES } from '@/i18n/config' import { listContentSlugs, listPublishedSlugs } from '@/lib/content' @@ -62,9 +70,8 @@ async function generateSitemap(): Promise { }) } - // Receive money pages (unique sending countries from corridors) - const receiveSources = [...new Set(CORRIDORS.map((c) => c.from))] - for (const source of receiveSources) { + // Receive money pages — corridor origins that have a receive-from article + for (const source of RECEIVE_SOURCES) { pages.push({ path: `/${locale}/receive-money-from/${source}`, priority: 0.7 * basePriority, diff --git a/src/components/AddWithdraw/AddWithdrawCountriesList.tsx b/src/components/AddWithdraw/AddWithdrawCountriesList.tsx index cd3245c24..c287f5517 100644 --- a/src/components/AddWithdraw/AddWithdrawCountriesList.tsx +++ b/src/components/AddWithdraw/AddWithdrawCountriesList.tsx @@ -32,6 +32,7 @@ import { InitiateKycModal } from '@/components/Kyc/InitiateKycModal' import { useCapabilities } from '@/hooks/useCapabilities' import { getKycModalVariant, getGateUserMessage } from '@/utils/capability-gate' import { railJurisdictionForBank } from '@/utils/bridge.utils' +import { getRegionIntent } from '@/utils/regions.utils' import { useTosGuard } from '@/hooks/useTosGuard' import { BridgeTosStep } from '@/components/Kyc/BridgeTosStep' import { useModalsContext } from '@/context/ModalsContext' @@ -59,7 +60,7 @@ const AddWithdrawCountriesList = ({ flow }: AddWithdrawCountriesListProps) => { // inline sumsub kyc flow for bridge bank users who need verification // regionIntent is NOT passed here to avoid creating a backend record on mount. - // intent is passed at call time: handleInitiateKyc('STANDARD') + // intent is passed at call time, derived from the destination country. const sumsubFlow = useMultiPhaseKycFlow({ onKycSuccess: () => { setIsKycModalOpen(false) @@ -235,7 +236,7 @@ const AddWithdrawCountriesList = ({ flow }: AddWithdrawCountriesListProps) => { // scenario (2): if the user hasn't completed kyc yet // name and email are now collected by sumsub sdk — no need to save them beforehand if (!isUserKycApproved) { - await sumsubFlow.handleInitiateKyc('STANDARD') + await sumsubFlow.handleInitiateKyc(getRegionIntent(currentCountry?.region ?? 'rest-of-the-world')) } return {} @@ -342,7 +343,7 @@ const AddWithdrawCountriesList = ({ flow }: AddWithdrawCountriesListProps) => { await sumsubFlow.handleSelfHealResubmit('BRIDGE') } else { await sumsubFlow.handleInitiateKyc( - 'STANDARD', + getRegionIntent(currentCountry?.region ?? 'rest-of-the-world'), undefined, gate.kind === 'needs-enrollment' || undefined ) diff --git a/src/components/Badges/badge.utils.ts b/src/components/Badges/badge.utils.ts index 2ece81116..50a880727 100644 --- a/src/components/Badges/badge.utils.ts +++ b/src/components/Badges/badge.utils.ts @@ -171,6 +171,24 @@ export const BADGES: Record = { description: 'Found on Arbitrum — mutual onboarding achieved.', displayName: 'Arbitrum Native', }, + // Event badges — assets shipped to main via the May 29 hotfix but the catalog + // entries were dropped when the parallel maps collapsed into this single BADGES + // record, so the backend codes fell back to the Peanutman logo + raw backend name. + TOKEN_NATION_SP_2026: { + path: '/badges/token_nation_2026.svg', + description: 'São Paulo, baby. They came, they claimed, they tagged the wall.', + }, + ETHFLORIPA_HUB: { + path: '/badges/ethfloripa_hub.svg', + description: 'Ilha da Magia, baby. Coconuts and consensus.', + displayName: 'Ethereum Hub Floripa', + }, + // Skip Pass — friends-of-Peanut who bypassed the waitlist via /invite?campaign=skip. + // Awarded by the backend /badge/award endpoint, which also flips hasAppAccess. + WAITLIST_SKIP: { + path: '/badges/skip_pass.svg', + description: 'They skipped the waitlist. A friend handed them the key and they walked right in.', + }, } /** All known badge codes — derived from BADGES so we never duplicate the diff --git a/src/components/Card/CardTermsScreen.tsx b/src/components/Card/CardTermsScreen.tsx index 2cc410c70..481689b7f 100644 --- a/src/components/Card/CardTermsScreen.tsx +++ b/src/components/Card/CardTermsScreen.tsx @@ -17,12 +17,13 @@ interface Props { // stands in for Peanut. Swap if the partnership agreement renames the card. const CARD_PARTNER_NAME = 'Peanut' +// US and international cardholders accept different card-terms documents. const LINKS = { eSign: 'https://legal.raincards.xyz/legal/electronic-communications-notice', issuerPrivacy: 'https://www.third-national.com/privacypolicy', - // TODO: swap placeholders with the real links when Rain/partner share them. - cardTerms: '#', - accountOpeningPrivacy: '#', + cardTermsUs: 'https://peanut.me/en/card-terms-us', + cardTermsInternational: 'https://peanut.me/en/card-terms-international', + accountOpeningPrivacy: 'https://peanut.me/en/card-privacy', } interface Term { @@ -36,49 +37,53 @@ const ExternalLink: FC<{ href: string; children: ReactNode }> = ({ href, childre ) -const INT_TERMS: Term[] = [ - { - id: 'esign', - label: ( - <> - I accept the E-Sign Consent - > - ), - }, - { - id: 'cardTermsIssuer', - label: ( - <> - I accept the {CARD_PARTNER_NAME} Card Terms - {', and the '} - Issuer Privacy Policy - > - ), - }, - { - id: 'accuracy', - label: `I certify that the information I have provided is accurate and that I will abide by all the rules and requirements related to my ${CARD_PARTNER_NAME} Spend Card.`, - }, - { - id: 'solicitation', - label: `I acknowledge that applying for the ${CARD_PARTNER_NAME} Spend Card does not constitute unauthorized solicitation.`, - }, -] +const esignTerm: Term = { + id: 'esign', + label: ( + <> + I accept the E-Sign Consent + > + ), +} + +const cardTermsIssuerTerm = (cardTermsHref: string): Term => ({ + id: 'cardTermsIssuer', + label: ( + <> + I accept the {CARD_PARTNER_NAME} Card Terms + {', and the '} + Issuer Privacy Policy + > + ), +}) + +const accuracyTerm: Term = { + id: 'accuracy', + label: `I certify that the information I have provided is accurate and that I will abide by all the rules and requirements related to my ${CARD_PARTNER_NAME} Spend Card.`, +} + +const solicitationTerm: Term = { + id: 'solicitation', + label: `I acknowledge that applying for the ${CARD_PARTNER_NAME} Spend Card does not constitute unauthorized solicitation.`, +} + +const accountOpeningPrivacyTerm: Term = { + id: 'accountOpeningPrivacy', + label: ( + <> + I accept the Account Opening Privacy Notice + > + ), +} + +const INT_TERMS: Term[] = [esignTerm, cardTermsIssuerTerm(LINKS.cardTermsInternational), accuracyTerm, solicitationTerm] const US_TERMS: Term[] = [ - INT_TERMS[0], - { - id: 'accountOpeningPrivacy', - label: ( - <> - I accept the{' '} - Account Opening Privacy Notice - > - ), - }, - INT_TERMS[1], - INT_TERMS[2], - INT_TERMS[3], + esignTerm, + accountOpeningPrivacyTerm, + cardTermsIssuerTerm(LINKS.cardTermsUs), + accuracyTerm, + solicitationTerm, ] const CardTermsScreen: FC = ({ isUsResident, onAccept, onPrev, submitError }) => { diff --git a/src/components/Claim/Link/views/BankFlowManager.view.tsx b/src/components/Claim/Link/views/BankFlowManager.view.tsx index 60fbb46c7..12c210953 100644 --- a/src/components/Claim/Link/views/BankFlowManager.view.tsx +++ b/src/components/Claim/Link/views/BankFlowManager.view.tsx @@ -32,6 +32,7 @@ import { bankFormActions } from '@/redux/slices/bank-form-slice' import { sendLinksApi } from '@/services/sendLinks' import { useSearchParams } from 'next/navigation' import { useMultiPhaseKycFlow } from '@/hooks/useMultiPhaseKycFlow' +import { getRegionIntent } from '@/utils/regions.utils' import { SumsubKycModals } from '@/components/Kyc/SumsubKycModals' import { useCapabilities } from '@/hooks/useCapabilities' import { getKycModalVariant, getGateUserMessage } from '@/utils/capability-gate' @@ -93,7 +94,7 @@ export const BankFlowManager = (props: IClaimScreenProps) => { // inline sumsub kyc flow for users who need verification // regionIntent is NOT passed here to avoid creating a backend record on mount. - // intent is passed at call time: handleInitiateKyc('STANDARD') + // intent is passed at call time, derived from the destination country. const sumsubFlow = useMultiPhaseKycFlow({ onKycSuccess: async () => { if (justCompletedKyc) return @@ -296,7 +297,7 @@ export const BankFlowManager = (props: IClaimScreenProps) => { // scenario 1: receiver needs KYC // name and email are now collected by sumsub sdk — no need to save them beforehand if (bankClaimType === BankClaimType.ReceiverKycNeeded && !justCompletedKyc) { - await sumsubFlow.handleInitiateKyc('STANDARD') + await sumsubFlow.handleInitiateKyc(getRegionIntent(selectedCountry?.region ?? 'rest-of-the-world')) return {} } @@ -564,7 +565,7 @@ export const BankFlowManager = (props: IClaimScreenProps) => { await sumsubFlow.handleSelfHealResubmit('BRIDGE') } else { await sumsubFlow.handleInitiateKyc( - 'STANDARD', + getRegionIntent(selectedCountry?.region ?? 'rest-of-the-world'), undefined, gate.kind === 'needs-enrollment' || undefined ) diff --git a/src/components/Invites/InvitesPage.tsx b/src/components/Invites/InvitesPage.tsx index 6592c9b6b..52cb1e61b 100644 --- a/src/components/Invites/InvitesPage.tsx +++ b/src/components/Invites/InvitesPage.tsx @@ -1,5 +1,5 @@ 'use client' -import { Suspense, useEffect, useRef, useState, useCallback } from 'react' +import { Suspense, useEffect, useRef, useState } from 'react' import PeanutLoading from '../Global/PeanutLoading' import ValidationErrorView from '../Payment/Views/Error.validation.view' import InvitesPageLayout from './InvitesPageLayout' @@ -39,6 +39,11 @@ const UTM_CAMPAIGN_TO_BADGE_MAP: Record = { ethfloripa: 'ETHFLORIPA_HUB', } +// /invite?campaign=skip — bypasses the waitlist with no invite code. The backend +// /badge/award endpoint flips hasAppAccess + cardFlowEarlyAccessAt and adds the +// Skip Pass badge (which is also in SKIP_BADGE_CODES, so card-waitlist is bypassed). +const SKIP_CAMPAIGN = 'skip' + function InvitePageContent() { const searchParams = useSearchParams() // trim trailing '?' from invite code to handle qr codes with ? at the end @@ -57,6 +62,11 @@ function InvitePageContent() { (inviteCode ? INVITE_CODE_TO_CAMPAIGN_MAP[inviteCode] : undefined) || (utmCampaignParam ? UTM_CAMPAIGN_TO_BADGE_MAP[utmCampaignParam] : undefined) + // Skip-the-waitlist link: no invite code, ?campaign=skip. The auto-claim + // effect below treats it specially (fires even without hasAppAccess) since + // awarding the badge IS what grants access. + const isSkipCampaign = !inviteCode && campaign?.toLowerCase() === SKIP_CAMPAIGN + const dispatch = useAppDispatch() const router = useRouter() const { handleLoginClick, isLoggingIn } = useLogin() @@ -90,98 +100,97 @@ function InvitePageContent() { // determine if we should show content based on user state useEffect(() => { - // if still fetching user, don't show content yet if (isFetchingUser) { setShouldShowContent(false) return } - - // if invite validation is still loading, don't show content yet - if (isLoading) { + // wait for the invite query if there is one + if (inviteCode && isLoading) { setShouldShowContent(false) return } - - // if user has app access AND invite is valid, they'll be redirected - // don't show content in this case (show loading instead) - if (!redirectUri && user?.user?.hasAppAccess && inviteCodeData?.success) { + // a logged-in visitor on either claim path will be auto-routed by the + // effect below — keep the loading spinner so they don't see the CTA flash. + if (user?.user && (isSkipCampaign || (!redirectUri && user.user.hasAppAccess && inviteCodeData?.success))) { setShouldShowContent(false) return } - - // otherwise, safe to show content (either error view or invite screen) setShouldShowContent(true) - }, [user, isFetchingUser, redirectUri, inviteCodeData, isLoading]) + }, [user, isFetchingUser, redirectUri, inviteCodeData, isLoading, isSkipCampaign, inviteCode]) - // redirect logged-in users who already have app access - // users without app access should stay on this page to claim the invite and get access + // Logged-in auto-claim: award the campaign badge then push /home, or fall + // back to the inviter's profile when there's a valid invite but no campaign. + // Fires on both the invite-code path (needs hasAppAccess) and the Skip Pass + // path (badge GRANTS access, so no hasAppAccess gate). useEffect(() => { - // wait for both user and invite data to be loaded - if (!user?.user || !inviteCodeData || isLoading || isFetchingUser) { - return - } - - // prevent running the effect multiple times (ref doesn't trigger re-renders) - if (hasStartedAwardingRef.current) { + if (!user?.user || isFetchingUser || hasStartedAwardingRef.current) return + // wait for invite-code validation when there is one + if (inviteCode && (isLoading || !inviteCodeData)) return + + const hasValidInvite = !!inviteCodeData?.success && !!inviteCodeData.username + const isInviteAutoClaim = !redirectUri && user.user.hasAppAccess && hasValidInvite + if (!isInviteAutoClaim && !isSkipCampaign) return + + hasStartedAwardingRef.current = true + + if (campaign) { + setIsAwardingBadge(true) + invitesApi + .awardBadge(campaign) + .catch((e) => console.error('Error awarding campaign badge', e)) + .finally(async () => { + await fetchUser() + setIsAwardingBadge(false) + router.push('/home') + }) return } - // if user has app access and invite is valid, handle redirect - if (!redirectUri && user.user.hasAppAccess && inviteCodeData.success && inviteCodeData.username) { - // if campaign is present, award the badge and redirect to home - if (campaign) { - hasStartedAwardingRef.current = true - setIsAwardingBadge(true) - invitesApi - .awardBadge(campaign) - .then(async () => { - // refetch user data to get the newly awarded badge - await fetchUser() - router.push('/home') - }) - .catch(async () => { - // if badge awarding fails, still refetch and redirect - await fetchUser() - router.push('/home') - }) - .finally(() => { - setIsAwardingBadge(false) - }) - } else { - // no campaign, just redirect to inviter's profile - router.push(profileUrl(inviteCodeData.username)) - } + // No campaign on a validated invite → route to inviter profile. + if (hasValidInvite) { + router.push(profileUrl(inviteCodeData!.username!)) } - }, [user, inviteCodeData, isLoading, isFetchingUser, router, campaign, redirectUri, fetchUser]) + }, [ + user, + inviteCodeData, + isLoading, + isFetchingUser, + router, + campaign, + redirectUri, + fetchUser, + isSkipCampaign, + inviteCode, + ]) + + const handleClaim = () => { + const eventTag = inviteCode || (isSkipCampaign ? SKIP_CAMPAIGN : undefined) + posthog.capture(ANALYTICS_EVENTS.INVITE_CLAIM_CLICKED, { invite_code: eventTag }) - const handleClaimInvite = async () => { if (inviteCode) { - posthog.capture(ANALYTICS_EVENTS.INVITE_CLAIM_CLICKED, { - invite_code: inviteCode, - }) dispatch(setupActions.setInviteCode(inviteCode)) dispatch(setupActions.setInviteType(EInviteType.PAYMENT_LINK)) - saveToCookie('inviteCode', inviteCode) // Save to cookies as well, so that if user installs PWA, they can still use the invite code - if (campaign) { - saveToCookie('campaignTag', campaign) - } - if (redirectUri) { - const encodedRedirectUri = encodeURIComponent(redirectUri) - router.push('/setup?step=signup&redirect_uri=' + encodedRedirectUri) - } else { - router.push('/setup?step=signup') - } + // Save to cookie so PWA-install + later signup still see the invite. + saveToCookie('inviteCode', inviteCode) } + if (campaign) { + // useZeroDev reads `campaignTag` post-signup and calls /badge/award. + saveToCookie('campaignTag', campaign) + } + + const signupUrl = redirectUri + ? `/setup?step=signup&redirect_uri=${encodeURIComponent(redirectUri)}` + : '/setup?step=signup' + router.push(signupUrl) } - // show loading if: - // 1. badge is being awarded - // 2. we determined content shouldn't be shown yet (covers user fetching + invite validation) if (isAwardingBadge || !shouldShowContent) { return } - if (isError || !inviteCodeData?.success) { + // Invalid invite code (only reachable when an invite code was supplied). + // The skip path has no invite code so it never falls here. + if (!isSkipCampaign && (isError || !inviteCodeData?.success)) { return ( - {inviteCodeData?.username} invited you to Peanut - - Members-only access. Use this invite to open your wallet and start sending and receiving - money globally. - - - Claim your spot + {title} + {description} + + {ctaLabel} {!user?.user && ( diff --git a/src/components/LandingPage/Footer.tsx b/src/components/LandingPage/Footer.tsx index 90bdf44fc..bc6630e88 100644 --- a/src/components/LandingPage/Footer.tsx +++ b/src/components/LandingPage/Footer.tsx @@ -63,18 +63,18 @@ const Footer = ({ showSiteDirectory = true, locale = 'en' }: { showSiteDirectory Support - + Content - - + + Docs - - + + Terms - - + + Privacy - + { const mantecaRejection = useMemo(() => deriveProviderRejection(rails, 'MANTECA'), [rails]) const isSumsubApproved = isKycApproved // MIGRATION-REVIEW: the existing verification's region intent (was - // useUnifiedKycStatus.sumsubVerificationRegionIntent, read off raw kycVerifications - // metadata) is proxied from which provider the user already holds a functional rail - // for: a Manteca rail ⇒ they verified for LATAM, a Bridge rail ⇒ STANDARD. Used only + // Which provider the user already holds a functional rail for, used only // to detect a cross-region switch when starting a new verification. - const sumsubVerificationRegionIntent: string | null = useMemo(() => { + // We deliberately capture the PROVIDER directly rather than round-tripping + // through KYCRegionIntent: the legacy 'STANDARD' value used to stand in + // for "Bridge rail" here, but providerForRegionIntent maps STANDARD → + // manteca (it's in the general-bucket fallback), so a Bridge-→Bridge + // re-verification would have misclassified as cross-region. + const existingVerificationProvider: ProviderCode | null = useMemo(() => { const hasFunctional = (provider: ProviderCode) => railsForProvider(provider).some( (rail) => rail.status === 'enabled' || rail.status === 'pending' || rail.status === 'requires-info' ) - if (hasFunctional('manteca')) return 'LATAM' - if (hasFunctional('bridge')) return 'STANDARD' + if (hasFunctional('manteca')) return 'manteca' + if (hasFunctional('bridge')) return 'bridge' return null }, [railsForProvider]) const { setIsSupportModalOpen } = useModalsContext() @@ -165,21 +168,17 @@ const UnlockedRegions = () => { const intent = selectedRegion ? getRegionIntent(selectedRegion.path) : undefined if (intent) setActiveRegionIntent(intent) // Cross-region means "user has an identity for one provider, clicked a - // region served by a DIFFERENT provider". Compare PROVIDERS not raw - // intent strings: in the 4-bucket model both 'EU' and 'NA' route to - // Bridge, and both 'LATAM' / 'ROW' route to Manteca (`general` level), - // so 'STANDARD' !== 'EU' alone would misclassify a Bridge→Bridge flow - // as cross-region. + // region served by a DIFFERENT provider". Compare PROVIDERS directly: + // in the 4-bucket model both 'EU' and 'NA' route to Bridge and both + // 'LATAM' / 'ROW' route to Manteca, so an intent-string compare would + // miss Bridge-EU → Bridge-NA same-provider flows. const crossRegion = - sumsubVerificationRegionIntent && - intent && - providerForRegionIntent(sumsubVerificationRegionIntent as KYCRegionIntent) !== - providerForRegionIntent(intent) + existingVerificationProvider && intent && existingVerificationProvider !== providerForRegionIntent(intent) ? true : undefined setSelectedRegion(null) await flow.handleInitiateKyc(intent, undefined, crossRegion) - }, [flow.handleInitiateKyc, selectedRegion, sumsubVerificationRegionIntent]) + }, [flow.handleInitiateKyc, selectedRegion, existingVerificationProvider]) // re-submission: skip StartVerificationView since user already consented const handleResubmitKyc = useCallback(async () => { diff --git a/src/components/Setup/Views/JoinWaitlist.tsx b/src/components/Setup/Views/JoinWaitlist.tsx index 2bb6c78c2..76c3e88e3 100644 --- a/src/components/Setup/Views/JoinWaitlist.tsx +++ b/src/components/Setup/Views/JoinWaitlist.tsx @@ -37,13 +37,21 @@ const JoinWaitlist = () => { setisLoading(true) const res = await invitesApi.validateInviteCode(inviteCode) const isValid = res.success - posthog.capture(ANALYTICS_EVENTS.INVITE_CODE_VALIDATED, { valid: isValid, source: 'setup' }) + posthog.capture(ANALYTICS_EVENTS.INVITE_CODE_VALIDATED, { + valid: isValid, + source: 'setup', + invite_code: inviteCode, + }) if (!isValid) { setError("We couldn't find that user") } return isValid } catch { - posthog.capture(ANALYTICS_EVENTS.INVITE_CODE_VALIDATED, { valid: false, source: 'setup' }) + posthog.capture(ANALYTICS_EVENTS.INVITE_CODE_VALIDATED, { + valid: false, + source: 'setup', + invite_code: inviteCode, + }) setError("We couldn't find that user") return false } finally { diff --git a/src/components/Setup/Views/Signup.tsx b/src/components/Setup/Views/Signup.tsx index a631e22bc..b0abdf204 100644 --- a/src/components/Setup/Views/Signup.tsx +++ b/src/components/Setup/Views/Signup.tsx @@ -60,6 +60,7 @@ const SignupStep = () => { posthog.capture(ANALYTICS_EVENTS.SIGNUP_USERNAME_VALIDATED, { is_valid: false, error_type: 'taken', + username, }) return false case 400: @@ -67,12 +68,13 @@ const SignupStep = () => { posthog.capture(ANALYTICS_EVENTS.SIGNUP_USERNAME_VALIDATED, { is_valid: false, error_type: 'invalid', + username, }) return false case 404: // handle is available setError('') - posthog.capture(ANALYTICS_EVENTS.SIGNUP_USERNAME_VALIDATED, { is_valid: true }) + posthog.capture(ANALYTICS_EVENTS.SIGNUP_USERNAME_VALIDATED, { is_valid: true, username }) return true default: // we dont expect any other status code diff --git a/src/context/authContext.tsx b/src/context/authContext.tsx index 7d9f8d4e5..9d95cd6fe 100644 --- a/src/context/authContext.tsx +++ b/src/context/authContext.tsx @@ -21,7 +21,7 @@ import posthog from 'posthog-js' import { useQueryClient } from '@tanstack/react-query' import { useRouter } from 'next/navigation' import { createContext, type ReactNode, useContext, useState, useEffect, useMemo, useCallback } from 'react' -import { captureException } from '@sentry/nextjs' +import { captureException, setUser as setSentryUser } from '@sentry/nextjs' // import { PUBLIC_ROUTES_REGEX } from '@/constants/routes' import { USER_DATA_CACHE_PATTERNS } from '@/constants/cache.consts' @@ -90,6 +90,19 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => { posthog.identify(user.user.userId, { username: user.user.username, }) + // Sentry: every error captured from here on inherits user context + // as searchable Sentry tags. Closes the historical gap where FE + // errors were anonymous and had to be cross-referenced via the + // posthog $sentry_url field to figure out which user hit them. + setSentryUser({ + id: user.user.userId, + username: user.user.username ?? undefined, + email: user.user.email ?? undefined, + }) + } else { + // Logout / unauthenticated: clear Sentry user so subsequent + // anonymous-session errors don't get misattributed. + setSentryUser(null) } }, [user]) diff --git a/src/data/seo/comparisons.ts b/src/data/seo/comparisons.ts index f59cc3f1c..65d143581 100644 --- a/src/data/seo/comparisons.ts +++ b/src/data/seo/comparisons.ts @@ -19,9 +19,10 @@ export interface Competitor { function loadCompetitors(): Record { const result: Record = {} for (const slug of listContentSlugs('compare')) { + // Skip slug dirs with no en.md — /compare/peanut-vs-{slug} would 404. const content = readPageContent<{ name?: unknown; published?: boolean }>('compare', slug, 'en') - if (content && content.frontmatter.published === false) continue - result[slug] = { name: displayNameFromContent(slug, content?.frontmatter) } + if (!content || content.frontmatter.published === false) continue + result[slug] = { name: displayNameFromContent(slug, content.frontmatter) } } return result } diff --git a/src/data/seo/corridors.test.ts b/src/data/seo/corridors.test.ts new file mode 100644 index 000000000..3bc5335dd --- /dev/null +++ b/src/data/seo/corridors.test.ts @@ -0,0 +1,32 @@ +import fs from 'fs' +import path from 'path' +import { CORRIDORS, RECEIVE_SOURCES } from './corridors' + +const RECEIVE_FROM_DIR = path.join(process.cwd(), 'src/content/content/receive-from') + +// Regression guard for the May 2026 live 404s: receive-money-from rendered for +// every corridor origin, but origins lacking a receive-from article (colombia, +// mexico) 404ed. RECEIVE_SOURCES must be CORRIDORS.from gated by content. +describe('RECEIVE_SOURCES', () => { + it('only contains corridor origins', () => { + const origins = new Set(CORRIDORS.map((c) => c.from)) + for (const slug of RECEIVE_SOURCES) { + expect(origins.has(slug)).toBe(true) + } + }) + + it('only contains origins that have a receive-from article (no 404s)', () => { + for (const slug of RECEIVE_SOURCES) { + const enFile = path.join(RECEIVE_FROM_DIR, slug, 'en.md') + expect(fs.existsSync(enFile)).toBe(true) + } + }) + + it('drops corridor origins with no receive-from article', () => { + const origins = [...new Set(CORRIDORS.map((c) => c.from))] + for (const slug of origins) { + const hasArticle = fs.existsSync(path.join(RECEIVE_FROM_DIR, slug, 'en.md')) + expect(RECEIVE_SOURCES.includes(slug)).toBe(hasArticle) + } + }) +}) diff --git a/src/data/seo/corridors.ts b/src/data/seo/corridors.ts index 104ed13a6..fd13254a1 100644 --- a/src/data/seo/corridors.ts +++ b/src/data/seo/corridors.ts @@ -16,7 +16,13 @@ // — now render the MDX body directly. We keep only the slug list and the // display-name resolver. -import { listContentSlugs, listCorridorOrigins, readPageContent, readPageContentLocalized } from '@/lib/content' +import { + listContentSlugs, + listCorridorOrigins, + readCorridorContent, + readPageContent, + readPageContentLocalized, +} from '@/lib/content' import type { Locale } from '@/i18n/types' import { displayNameFromContent } from './utils' @@ -35,14 +41,16 @@ export interface Corridor { function loadCountries(): Record { const result: Record = {} for (const slug of listContentSlugs('countries')) { + // A slug directory with no en.md has no render target — the route + // would 404. Gate on content presence, not just directory existence. const content = readPageContent<{ name?: unknown; iso2?: unknown; published?: boolean }>( 'countries', slug, 'en' ) - if (content && content.frontmatter.published === false) continue - const iso2 = typeof content?.frontmatter.iso2 === 'string' ? content.frontmatter.iso2 : undefined - result[slug] = { name: displayNameFromContent(slug, content?.frontmatter), iso2 } + if (!content || content.frontmatter.published === false) continue + const iso2 = typeof content.frontmatter.iso2 === 'string' ? content.frontmatter.iso2 : undefined + result[slug] = { name: displayNameFromContent(slug, content.frontmatter), iso2 } } return result } @@ -52,6 +60,9 @@ function loadCorridors(): Corridor[] { const corridors: Corridor[] = [] for (const dest of listContentSlugs('send-to')) { for (const origin of listCorridorOrigins(dest)) { + // Skip corridor dirs with no en.md — send-money-from would 404. + const content = readCorridorContent<{ published?: boolean }>(dest, origin, 'en') + if (!content || content.frontmatter.published === false) continue const key = `${origin}→${dest}` if (seen.has(key)) continue seen.add(key) @@ -61,8 +72,25 @@ function loadCorridors(): Corridor[] { return corridors } +/** + * Origins for the receive-money-from pages. The set is the corridor origins, + * but only those that actually have a receive-from article — an origin present + * in CORRIDORS.from but missing content/receive-from/{slug}/en.md would 404 + * (this is how colombia & mexico shipped as live 404s in May 2026). The + * receive-from content tree is authored independently of corridors, so the two + * sets don't line up automatically. + */ +function loadReceiveSources(corridors: Corridor[]): string[] { + const origins = [...new Set(corridors.map((c) => c.from))] + return origins.filter((slug) => { + const content = readPageContent<{ published?: boolean }>('receive-from', slug, 'en') + return content !== null && content.frontmatter.published !== false + }) +} + export const COUNTRIES_SEO: Record = loadCountries() export const CORRIDORS: Corridor[] = loadCorridors() +export const RECEIVE_SOURCES: string[] = loadReceiveSources(CORRIDORS) /** * Get the country display name for a slug at the given locale. Reads diff --git a/src/data/seo/exchanges.ts b/src/data/seo/exchanges.ts index 566671193..7f8863adc 100644 --- a/src/data/seo/exchanges.ts +++ b/src/data/seo/exchanges.ts @@ -63,11 +63,12 @@ function loadExchanges(): Record { const result: Record = {} for (const slug of listContentSlugs('deposit')) { if (RAIL_SLUGS.has(slug)) continue + // Skip slug dirs with no en.md — /deposit/from-{slug} would 404. const content = readPageContent('deposit', slug, 'en') - if (content && content.frontmatter.published === false) continue + if (!content || content.frontmatter.published === false) continue result[slug] = { - name: displayNameFromContent(slug, content?.frontmatter), - recommendedNetwork: pickRecommendedNetwork(content?.frontmatter), + name: displayNameFromContent(slug, content.frontmatter), + recommendedNetwork: pickRecommendedNetwork(content.frontmatter), } } return result diff --git a/src/data/seo/index.ts b/src/data/seo/index.ts index a17b2ebd8..82a122ea2 100644 --- a/src/data/seo/index.ts +++ b/src/data/seo/index.ts @@ -1,4 +1,4 @@ -export { COUNTRIES_SEO, CORRIDORS, getCountryName } from './corridors' +export { COUNTRIES_SEO, CORRIDORS, RECEIVE_SOURCES, getCountryName } from './corridors' export { COMPETITORS } from './comparisons' diff --git a/src/data/seo/payment-methods.ts b/src/data/seo/payment-methods.ts index 5666f59bb..dcdc66046 100644 --- a/src/data/seo/payment-methods.ts +++ b/src/data/seo/payment-methods.ts @@ -20,9 +20,10 @@ export interface PaymentMethod { function loadPaymentMethods(): Record { const result: Record = {} for (const slug of listContentSlugs('pay-with')) { + // Skip slug dirs with no en.md — /pay-with/{slug} would 404. const content = readPageContent<{ name?: unknown; published?: boolean }>('pay-with', slug, 'en') - if (content && content.frontmatter.published === false) continue - result[slug] = { slug, name: displayNameFromContent(slug, content?.frontmatter) } + if (!content || content.frontmatter.published === false) continue + result[slug] = { slug, name: displayNameFromContent(slug, content.frontmatter) } } return result } diff --git a/src/data/seo/utils.test.ts b/src/data/seo/utils.test.ts new file mode 100644 index 000000000..5f5e620b2 --- /dev/null +++ b/src/data/seo/utils.test.ts @@ -0,0 +1,28 @@ +import { displayNameFromContent, titleCaseSlug } from './utils' + +describe('displayNameFromContent', () => { + it('returns the frontmatter name when present', () => { + expect(displayNameFromContent('wise', { name: 'Wise' })).toBe('Wise') + }) + + it('trims surrounding whitespace from the frontmatter name', () => { + // Validated with .trim() but used to return the raw value — whitespace + // leaked into breadcrumbs / link text. + expect(displayNameFromContent('wise', { name: ' Wise ' })).toBe('Wise') + expect(displayNameFromContent('wise', { name: 'Wise\n' })).toBe('Wise') + }) + + it('falls back to title-cased slug for missing / blank / non-string names', () => { + expect(displayNameFromContent('western-union', undefined)).toBe('Western Union') + expect(displayNameFromContent('western-union', { name: ' ' })).toBe('Western Union') + expect(displayNameFromContent('western-union', { name: 42 })).toBe('Western Union') + expect(displayNameFromContent('western-union', null)).toBe('Western Union') + }) +}) + +describe('titleCaseSlug', () => { + it('title-cases a kebab slug', () => { + expect(titleCaseSlug('united-kingdom')).toBe('United Kingdom') + expect(titleCaseSlug('binance-p2p')).toBe('Binance P2p') + }) +}) diff --git a/src/data/seo/utils.ts b/src/data/seo/utils.ts index d95d1f3c6..1862a2a76 100644 --- a/src/data/seo/utils.ts +++ b/src/data/seo/utils.ts @@ -16,7 +16,7 @@ interface Named { */ export function displayNameFromContent(slug: string, frontmatter: Named | null | undefined): string { const name = frontmatter?.name - if (typeof name === 'string' && name.trim().length > 0) return name + if (typeof name === 'string' && name.trim().length > 0) return name.trim() return titleCaseSlug(slug) } diff --git a/src/hooks/__tests__/useGetExchangeRate.test.ts b/src/hooks/__tests__/useGetExchangeRate.test.ts new file mode 100644 index 000000000..83ce77a2e --- /dev/null +++ b/src/hooks/__tests__/useGetExchangeRate.test.ts @@ -0,0 +1,63 @@ +import { renderHook, waitFor } from '@testing-library/react' +import React from 'react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import useGetExchangeRate from '@/hooks/useGetExchangeRate' +import { AccountType } from '@/interfaces' + +// `getExchangeRate` performs a server fetch in real use. We mock it to capture +// whether it was called — the regression test for PEANUT-UI-QHR is that +// non-Bridge AccountType values must NOT cause a network call. +const getExchangeRateMock = jest.fn() +jest.mock('@/app/actions/exchange-rate', () => ({ + getExchangeRate: (...args: unknown[]) => getExchangeRateMock(...args), +})) + +const wrapper = ({ children }: { children: React.ReactNode }) => { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: 0, staleTime: 0 } }, + }) + return React.createElement(QueryClientProvider, { client }, children) +} + +describe('useGetExchangeRate', () => { + beforeEach(() => { + getExchangeRateMock.mockReset() + }) + + it('returns 1 and skips the network call for AccountType.US (USD↔USD)', async () => { + const { result } = renderHook(() => useGetExchangeRate({ accountType: AccountType.US }), { wrapper }) + await waitFor(() => expect(result.current.exchangeRate).toBe('1')) + expect(getExchangeRateMock).not.toHaveBeenCalled() + }) + + // PEANUT-UI-QHR regression: the BE `/bridge/exchange-rate` enum is + // {iban,us,clabe,gb}. Any other AccountType (MANTECA, EVM_ADDRESS, + // PEANUT_WALLET) would 400 — the hook must short-circuit instead of + // making the network call. + it.each([AccountType.MANTECA, AccountType.EVM_ADDRESS, AccountType.PEANUT_WALLET])( + 'returns 1 and skips the network call for non-Bridge type: %s', + async (accountType) => { + const { result } = renderHook(() => useGetExchangeRate({ accountType }), { wrapper }) + await waitFor(() => expect(result.current.exchangeRate).toBe('1')) + expect(getExchangeRateMock).not.toHaveBeenCalled() + } + ) + + it.each([AccountType.IBAN, AccountType.CLABE, AccountType.GB])( + 'calls the network for Bridge FX type: %s and returns its sell_rate', + async (accountType) => { + getExchangeRateMock.mockResolvedValue({ data: { sell_rate: '1.05' } }) + const { result } = renderHook(() => useGetExchangeRate({ accountType }), { wrapper }) + await waitFor(() => expect(result.current.exchangeRate).toBe('1.05')) + expect(getExchangeRateMock).toHaveBeenCalledWith(accountType) + } + ) + + it('falls back to 1 when the network returns an error', async () => { + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}) + getExchangeRateMock.mockResolvedValue({ error: 'upstream 500' }) + const { result } = renderHook(() => useGetExchangeRate({ accountType: AccountType.IBAN }), { wrapper }) + await waitFor(() => expect(result.current.exchangeRate).toBe('1')) + consoleErrorSpy.mockRestore() + }) +}) diff --git a/src/hooks/__tests__/useSavedAccounts.test.ts b/src/hooks/__tests__/useSavedAccounts.test.ts new file mode 100644 index 000000000..a77d32d3a --- /dev/null +++ b/src/hooks/__tests__/useSavedAccounts.test.ts @@ -0,0 +1,46 @@ +import { renderHook } from '@testing-library/react' +import useSavedAccounts from '@/hooks/useSavedAccounts' +import { AccountType } from '@/interfaces/interfaces' + +const mockUseAuth = jest.fn() +jest.mock('@/context/authContext', () => ({ + useAuth: () => mockUseAuth(), +})) + +const acct = (type: AccountType, identifier: string) => ({ type, identifier, id: identifier }) + +beforeEach(() => mockUseAuth.mockReset()) + +describe('useSavedAccounts', () => { + it('returns [] when there is no user', () => { + mockUseAuth.mockReturnValue({ user: undefined }) + const { result } = renderHook(() => useSavedAccounts()) + expect(result.current).toEqual([]) + }) + + it('includes Manteca (AR CBU/CVU, BR PIX) accounts alongside Bridge bank types', () => { + // Regression: Manteca accounts (projected by the backend as type + // 'manteca') were dropped, so AR/BR users saw an empty saved-accounts + // list and had to re-enter their bank account every time. + mockUseAuth.mockReturnValue({ + user: { + accounts: [ + acct(AccountType.PEANUT_WALLET, '0xabc'), + acct(AccountType.EVM_ADDRESS, '0xdef'), + acct(AccountType.IBAN, 'DE00'), + acct(AccountType.MANTECA, '0070075730004135153296'), + ], + }, + }) + const { result } = renderHook(() => useSavedAccounts()) + expect(result.current.map((a) => a.type)).toEqual([AccountType.IBAN, AccountType.MANTECA]) + }) + + it('excludes wallets and other non-bank account types', () => { + mockUseAuth.mockReturnValue({ + user: { accounts: [acct(AccountType.PEANUT_WALLET, '0xabc'), acct(AccountType.EVM_ADDRESS, '0xdef')] }, + }) + const { result } = renderHook(() => useSavedAccounts()) + expect(result.current).toEqual([]) + }) +}) diff --git a/src/hooks/useGetExchangeRate.tsx b/src/hooks/useGetExchangeRate.tsx index 72f4af574..ec7f79567 100644 --- a/src/hooks/useGetExchangeRate.tsx +++ b/src/hooks/useGetExchangeRate.tsx @@ -11,12 +11,24 @@ export interface IExchangeRate { * Used to get exchange rate for a given account type using TanStack Query * @returns {string, boolean} The exchange rate for the given account type and a boolean indicating if the rate is being fetched */ +// `/bridge/exchange-rate` only serves Bridge bank-account types with a +// non-trivial FX rate: IBAN (EUR), CLABE (MXN), GB (GBP). US is USD↔USD = 1 +// by definition. Other AccountType values (MANTECA, EVM_ADDRESS, PEANUT_WALLET) +// aren't on the Bridge enum and 400 the endpoint (PEANUT-UI-QHR, 2026-06-02). +// Treat those like US — '1' is a safe display fallback; consumers already +// degrade gracefully on '1' (and the only consumer that needs a real Manteca +// FX rate routes through `getCachedCurrencyPrice` in actions/currency.ts). +// TODO: Manteca-currency display rates should route through that helper too +// instead of returning '1'; separate PR. +const BRIDGE_FX_ACCOUNT_TYPES: ReadonlySet = new Set([AccountType.IBAN, AccountType.CLABE, AccountType.GB]) + export default function useGetExchangeRate({ accountType, enabled = true }: IExchangeRate) { const { data: exchangeRate, isFetching: isFetchingRate } = useQuery({ queryKey: ['exchangeRate', accountType], queryFn: async () => { - // US accounts have 1:1 exchange rate - if (accountType === AccountType.US) { + // Anything not on the Bridge FX set returns the passthrough rate. + // Includes AccountType.US (USD↔USD = 1). + if (!BRIDGE_FX_ACCOUNT_TYPES.has(accountType)) { return '1' } diff --git a/src/hooks/useSavedAccounts.tsx b/src/hooks/useSavedAccounts.tsx index f34fb4a2b..de53407a9 100644 --- a/src/hooks/useSavedAccounts.tsx +++ b/src/hooks/useSavedAccounts.tsx @@ -1,18 +1,22 @@ 'use client' import { useAuth } from '@/context/authContext' -import { AccountType } from '@/interfaces' +import { AccountType } from '@/interfaces/interfaces' import { useMemo } from 'react' /** - * Used to get the user's saved accounts, for now limited to bank accounts with (IBAN, US, and CLABE) + * Used to get the user's saved bank accounts (IBAN, US/ACH, CLABE, GB sort-code, + * and Manteca LATAM accounts — AR CBU/CVU, BR PIX — which the backend projects + * under the single 'manteca' type). * NOTE: This hook can be extended to support more account types in the future based on requirements * @returns {array} An array of the user's saved bank accounts */ export default function useSavedAccounts() { const { user } = useAuth() - // filter out accounts that are not IBAN, US, or CLABE + // keep only saved bank accounts. Manteca (AR/BR) was previously omitted, so + // Manteca users' saved bank accounts never appeared in the claim/send-link + // bank flows — they had to re-enter their CBU/CVU/PIX every time. const savedAccounts = useMemo(() => { return ( user?.accounts.filter( @@ -20,7 +24,8 @@ export default function useSavedAccounts() { acc.type === AccountType.IBAN || acc.type === AccountType.US || acc.type === AccountType.CLABE || - acc.type === AccountType.GB + acc.type === AccountType.GB || + acc.type === AccountType.MANTECA ) ?? [] ) }, [user]) diff --git a/src/hooks/useZeroDev.ts b/src/hooks/useZeroDev.ts index 8dab4e0f4..1222a3894 100644 --- a/src/hooks/useZeroDev.ts +++ b/src/hooks/useZeroDev.ts @@ -120,6 +120,19 @@ export const useZeroDev = () => { }) console.error('Error accepting invite', e) } + } else if (campaignTag) { + // No invite code but a campaign tag — only InvitesPage's skip-path + // CTA reaches here today (it sets the cookie without an inviteCode). + // The BE whitelists which campaigns are claimable, so passing other + // values through is safe — anything not on the whitelist 400s. + try { + await invitesApi.awardBadge(campaignTag) + posthog.capture(ANALYTICS_EVENTS.INVITE_ACCEPTED, { campaign_tag: campaignTag }) + } catch (e) { + console.error('Error awarding campaign badge', e) + } finally { + removeFromCookie('campaignTag') + } } setWebAuthnKey(webAuthnKey) diff --git a/src/i18n/config.ts b/src/i18n/config.ts index 30ddd5c58..fa97becbf 100644 --- a/src/i18n/config.ts +++ b/src/i18n/config.ts @@ -20,6 +20,10 @@ export const ROUTE_SLUGS = [ 'supported-networks', 'terms', 'privacy', + 'card-terms-us', + 'card-terms-international', + 'card-privacy', + 'card-prohibited-activities', ] as const export type RouteSlug = (typeof ROUTE_SLUGS)[number] diff --git a/src/utils/__tests__/sentry.utils.test.ts b/src/utils/__tests__/sentry.utils.test.ts index bbf5b7f46..28d8c2796 100644 --- a/src/utils/__tests__/sentry.utils.test.ts +++ b/src/utils/__tests__/sentry.utils.test.ts @@ -1,19 +1,26 @@ -import { sanitizeRequestBody } from '../sentry.utils' +import { sanitizeRequestBody, sanitizeResponseBody, scrubObject } from '../sentry.utils' describe('sanitizeRequestBody', () => { - it('redacts the PIN-set endpoint body', () => { + it('redacts the PIN-set endpoint body wholesale (sensitive URL)', () => { const body = JSON.stringify({ pin: '1234' }) - expect(sanitizeRequestBody('https://api.peanut.me/rain/cards/abc-123/pin', body)).toBe('[REDACTED]') + expect(sanitizeRequestBody('https://api.peanut.me/rain/cards/abc-123/pin', body)).toBe( + '[REDACTED: sensitive endpoint]' + ) }) it('redacts regardless of cardId shape', () => { const body = JSON.stringify({ pin: '0000' }) - expect(sanitizeRequestBody('/rain/cards/00000000-0000-0000-0000-000000000000/pin', body)).toBe('[REDACTED]') + expect(sanitizeRequestBody('/rain/cards/00000000-0000-0000-0000-000000000000/pin', body)).toBe( + '[REDACTED: sensitive endpoint]' + ) }) - it('passes non-sensitive bodies through unchanged', () => { - const body = JSON.stringify({ amount: 1000 }) - expect(sanitizeRequestBody('https://api.peanut.me/rain/cards/abc-123/withdraw/submit', body)).toBe(body) + it('key-scrubs non-sensitive URL bodies (preserves shape, redacts PII keys)', () => { + const body = JSON.stringify({ amount: 1000, firstName: 'Hugo' }) + const out = sanitizeRequestBody('https://api.peanut.me/rain/cards/abc-123/withdraw/submit', body) as string + const parsed = JSON.parse(out) + expect(parsed.amount).toBe(1000) + expect(parsed.firstName).toBe('[REDACTED]') }) it('returns null for null/undefined bodies', () => { @@ -25,4 +32,171 @@ describe('sanitizeRequestBody', () => { const body = JSON.stringify({ thing: 'pin-pad' }) expect(sanitizeRequestBody('/rain/cards/abc-123/pinpad-config', body)).toBe(body) }) + + it('wholesale-redacts send-link password endpoints', () => { + const body = JSON.stringify({ password: 'hunter2' }) + expect(sanitizeRequestBody('/api/send-link/create', body)).toBe('[REDACTED: sensitive endpoint]') + expect(sanitizeRequestBody('/api/send-link/verify-password', body)).toBe('[REDACTED: sensitive endpoint]') + }) + + it('wholesale-redacts auth endpoints', () => { + const body = JSON.stringify({ email: 'x@example.com', password: 'hunter2' }) + expect(sanitizeRequestBody('/api/login', body)).toBe('[REDACTED: sensitive endpoint]') + expect(sanitizeRequestBody('/api/signup', body)).toBe('[REDACTED: sensitive endpoint]') + }) + + it('wholesale-redacts KYC endpoints', () => { + const body = JSON.stringify({ firstName: 'Hugo', dni: '12345678' }) + expect(sanitizeRequestBody('/api/kyc/start', body)).toBe('[REDACTED: sensitive endpoint]') + expect(sanitizeRequestBody('/api/bridge/customers', body)).toBe('[REDACTED: sensitive endpoint]') + }) +}) + +describe('sanitizeResponseBody', () => { + it('wholesale-redacts sensitive URL responses', () => { + expect(sanitizeResponseBody('/api/rain/cards/abc/pin', { ok: false })).toBe('[REDACTED: sensitive endpoint]') + }) + + it('key-scrubs non-sensitive URL responses (preserves shape, redacts PII keys)', () => { + const out = sanitizeResponseBody('/api/users/me', { + user: { email: 'hugo@peanut.me', firstName: 'Hugo', userId: 'u-1' }, + }) as { user: { email: string; firstName: string; userId: string } } + expect(out.user.email).toBe('hugo@peanut.me') + expect(out.user.userId).toBe('u-1') + expect(out.user.firstName).toBe('[REDACTED]') + }) +}) + +describe('scrubObject — exact-match contract', () => { + it('identity fields stay unredacted (already in PostHog)', () => { + const result = scrubObject({ + userId: 'u-1', + username: 'hugo', + email: 'hugo@peanut.me', + inviteCode: 'PEANUT42', + }) as Record + expect(result.userId).toBe('u-1') + expect(result.username).toBe('hugo') + expect(result.email).toBe('hugo@peanut.me') + expect(result.inviteCode).toBe('PEANUT42') + }) + + it('CRITICAL: onchain addresses stay visible (debugging onchain flows)', () => { + // If this test fails, someone substring-matched on `address` and broke + // debugging for every onchain flow in the app. + const result = scrubObject({ + address: '0xabc', + walletAddress: '0xdef', + recipientAddress: '0x123', + payerAddress: '0x456', + depositAddress: '0x789', + destinationAddress: 'TCNRtkx', + sdaAddress: '0xsda', + tokenAddress: '0xtoken', + contractAddress: '0xcontract', + }) as Record + expect(result.address).toBe('0xabc') + expect(result.walletAddress).toBe('0xdef') + expect(result.recipientAddress).toBe('0x123') + expect(result.payerAddress).toBe('0x456') + expect(result.depositAddress).toBe('0x789') + expect(result.destinationAddress).toBe('TCNRtkx') + expect(result.sdaAddress).toBe('0xsda') + expect(result.tokenAddress).toBe('0xtoken') + expect(result.contractAddress).toBe('0xcontract') + }) + + it('PII names get redacted (English + Bridge customer_* + Manteca Spanish)', () => { + const result = scrubObject({ + firstName: 'Hugo', + lastName: 'Monte', + customerFirstName: 'Hugo', + customerLastName: 'Monte', + nombre: 'Hugo', + apellido: 'Monte', + }) as Record + Object.values(result).forEach((v) => expect(v).toBe('[REDACTED]')) + }) + + it('home address variants redacted; crypto addresses NOT', () => { + const result = scrubObject({ + billingAddress: 'X', + mailingAddress: 'X', + homeAddress: 'X', + street_line_1: 'X', + direccion: 'X', + walletAddress: '0xstay', + }) as Record + expect(result.billingAddress).toBe('[REDACTED]') + expect(result.mailingAddress).toBe('[REDACTED]') + expect(result.homeAddress).toBe('[REDACTED]') + expect(result.street_line_1).toBe('[REDACTED]') + expect(result.direccion).toBe('[REDACTED]') + expect(result.walletAddress).toBe('0xstay') + }) + + it('card data', () => { + const result = scrubObject({ + pan: '4111', + cvv: '123', + cardPin: '1234', + pin: '1234', + expiryMonth: 12, + cardholderName: 'Hugo', + }) as Record + Object.values(result).forEach((v) => expect(v).toBe('[REDACTED]')) + }) + + it('government IDs (English + Bridge long-form + Manteca)', () => { + const result = scrubObject({ + ssn: '111', + social_security_number: '111', + tax_identification_number: 'X', + government_id_number: 'Z', + dni: '12345678', + documento: '12345678', + }) as Record + Object.values(result).forEach((v) => expect(v).toBe('[REDACTED]')) + }) + + it('bank accounts', () => { + const result = scrubObject({ + iban: 'ES12', + cbu: '0123', + clabe: '012345', + routingNumber: '021000021', + accountNumber: '9876', + }) as Record + Object.values(result).forEach((v) => expect(v).toBe('[REDACTED]')) + }) + + it('handles arrays + nested objects', () => { + const result = scrubObject({ + items: [{ pan: '4111' }], + nested: { profile: { firstName: 'Hugo', userId: 'u-1' } }, + }) as { items: Array<{ pan: string }>; nested: { profile: { firstName: string; userId: string } } } + expect(result.items[0].pan).toBe('[REDACTED]') + expect(result.nested.profile.firstName).toBe('[REDACTED]') + expect(result.nested.profile.userId).toBe('u-1') + }) + + it('depth-caps to defend against cycles (no throw)', () => { + const cyclic: Record = { name: 'root' } + cyclic.self = cyclic + expect(() => scrubObject(cyclic)).not.toThrow() + }) + + it('PROTO-POLLUTION DEFENSE: __proto__ / constructor / prototype dropped (CodeQL)', () => { + // JSON.parse creates __proto__ as an OWN property. Without the + // defense, scrubObject's `out[key] = …` walks into prototype + // pollution. Pinned because CodeQL flagged the equivalent shape. + const malicious = JSON.parse('{"__proto__":{"polluted":true},"constructor":"x","prototype":"y","clean":"z"}') + const out = scrubObject(malicious) as Record + expect(Object.prototype.hasOwnProperty.call(out, '__proto__')).toBe(false) + expect(Object.prototype.hasOwnProperty.call(out, 'constructor')).toBe(false) + expect(Object.prototype.hasOwnProperty.call(out, 'prototype')).toBe(false) + expect(out.clean).toBe('z') + // Global Object.prototype must NOT have been mutated + expect(({} as Record).polluted).toBeUndefined() + }) }) diff --git a/src/utils/capability-gate.test.ts b/src/utils/capability-gate.test.ts index 28e60be53..c3b84f030 100644 --- a/src/utils/capability-gate.test.ts +++ b/src/utils/capability-gate.test.ts @@ -315,3 +315,97 @@ describe('deriveGate — restart-identity vs contact-support split for blocked r expect(getKycModalVariant(gate.kind)).toBe('blocked') }) }) + +describe('deriveGate — ready-first ordering (Alexandre fix)', () => { + const tosAction: NextAction = { key: 'bridge.tos', kind: 'accept-tos', purpose: 'bridge-tos' } + const sumsubAction: NextAction = { key: 'bridge.rfi', kind: 'sumsub', purpose: 'bridge-rfi' } + + test('Alexandre case: PIX_BR ENABLED + Bridge × US/GB/EU/MX stuck on ToS → ready (no modal)', () => { + const pixBr = bankRail({ + id: 'manteca.pix_br', + provider: 'manteca', + method: 'PIX_BR', + country: 'BR', + currency: 'BRL', + status: 'enabled', + }) + const stuck = (id: `bridge.${string}`, country: string, currency: string, method: string): RailCapability => + bankRail({ + id, + provider: 'bridge', + method, + country, + currency, + status: 'requires-info', + blockingActions: ['bridge.tos'], + reason: { code: 'bridge_tos_v2_required', userMessage: 'Accept terms' }, + }) + const rails = [ + pixBr, + stuck('bridge.ach_us', 'US', 'USD', 'ACH_US'), + stuck('bridge.faster_payments_gb', 'GB', 'GBP', 'FASTER_PAYMENTS_GB'), + stuck('bridge.sepa_eu', 'EU', 'EUR', 'SEPA_EU'), + stuck('bridge.spei_mx', 'MX', 'MXN', 'SPEI_MX'), + ] + + const gate = deriveGate(state(rails, [tosAction]), 'deposit', { channel: 'bank' }) + expect(gate.kind).toBe('ready') + }) + + test('ready beats blocked-rejection — working rail trumps unrelated blocked one', () => { + const ready = bankRail({ id: 'manteca.pix_br', provider: 'manteca', country: 'BR', status: 'enabled' }) + const blockedTerminal = bankRail({ + id: 'bridge.ach_us', + status: 'blocked', + reason: { code: 'kyc_rejected_terminal', userMessage: 'Verification failed' }, + }) + + const gate = deriveGate(state([ready, blockedTerminal]), 'deposit', { channel: 'bank' }) + expect(gate.kind).toBe('ready') + }) + + test('ready beats fixable-rejection — RFI on another rail does not gate', () => { + const ready = bankRail({ id: 'manteca.pix_br', provider: 'manteca', country: 'BR', status: 'enabled' }) + const rfi = bankRail({ id: 'bridge.ach_us', status: 'requires-info', blockingActions: ['bridge.rfi'] }) + + const gate = deriveGate(state([ready, rfi], [sumsubAction]), 'deposit', { channel: 'bank' }) + expect(gate.kind).toBe('ready') + }) + + test('ready beats restart-identity — self-fixable blocked rail does not gate', () => { + const ready = bankRail({ id: 'manteca.pix_br', provider: 'manteca', country: 'BR', status: 'enabled' }) + const restartable = bankRail({ + id: 'bridge.ach_us', + status: 'blocked', + blockingActions: ['bridge.restart'], + }) + const restartAction: NextAction = { key: 'bridge.restart', kind: 'restart-identity', purpose: 'restart' } + + const gate = deriveGate(state([ready, restartable], [restartAction]), 'deposit', { channel: 'bank' }) + expect(gate.kind).toBe('ready') + }) + + test('no ready rail → falls through to existing blocked / accept-tos / fixable order', () => { + const tosRail = bankRail({ + id: 'bridge.ach_us', + status: 'requires-info', + blockingActions: ['bridge.tos'], + reason: { code: 'bridge_tos_required', userMessage: 'Accept terms' }, + }) + + const gate = deriveGate(state([tosRail], [tosAction]), 'deposit', { channel: 'bank' }) + expect(gate.kind).toBe('accept-tos') + }) + + test('per-op enabled status (not rail-level) is what matters', () => { + const railWithEnabledOp = bankRail({ + id: 'bridge.ach_us', + status: 'requires-info', + operations: { deposit: 'enabled', withdraw: 'requires-info' }, + blockingActions: ['bridge.rfi'], + }) + + const gate = deriveGate(state([railWithEnabledOp], [sumsubAction]), 'deposit', { channel: 'bank' }) + expect(gate.kind).toBe('ready') + }) +}) diff --git a/src/utils/capability-gate.ts b/src/utils/capability-gate.ts index 829f1ca57..5e3804187 100644 --- a/src/utils/capability-gate.ts +++ b/src/utils/capability-gate.ts @@ -112,13 +112,18 @@ export interface CapabilityState { * * Priority (highest first): * 1. loading — capability state not yet settled - * 2. blocked-rejection — any in-scope rail status: 'blocked' - * 3. accept-tos — requires-info + a `kind: 'accept-tos'` action + * 2. ready — at least one in-scope rail has operationStatus(op) === 'enabled'. + * Hoisted to position 2 so a working rail (e.g. Manteca PIX_BR + * ENABLED) wins over stuck sibling rails (e.g. Bridge ACH_US + * terms_of_service_v2). The 2026-06-01 Alexandre incident + * (BR user blocked by Bridge ToS modal while their Manteca + * PIX_BR was ENABLED) was the latest customer-visible failure + * of the prior 'gate any sibling blocker first' order. + * 3. blocked-rejection — any in-scope rail status: 'blocked', and no ready rail + * 4. accept-tos — requires-info + a `kind: 'accept-tos'` action * (user-actionable, unblocks the scope) - * 4. fixable-rejection — requires-info + a `kind: 'sumsub'` action + * 5. fixable-rejection — requires-info + a `kind: 'sumsub'` action * (user-actionable, unblocks the scope) - * 5. ready — at least one in-scope rail has operationStatus(op) === 'enabled' - * (user can transact NOW) * 6. pending — at least one in-scope rail status === 'pending' (we're provisioning) * 7. waiting-on-provider — only requires-info rails AND every one has only * `kind: 'wait'` actions (e.g. Bridge internal KYC @@ -138,7 +143,14 @@ export function deriveGate(state: CapabilityState, op: RailOperation, scope: Gat const candidates = filterRailsByScope(state.rails, scope) const actionByKey = new Map(state.nextActions.map((action) => [action.key, action])) - // 2. blocked — split: if the rail carries a `restart-identity` action the + // 2. ready — per-op refinement wins over rail-level status. Hoisted + // above blocked / accept-tos / fixable-rejection because the user has + // a working path; a blocked sibling rail (different currency, KYC + // remediation pending) is not the user's problem right now. + const hasReady = candidates.some((rail) => operationStatus(rail, op) === 'enabled') + if (hasReady) return { kind: 'ready' } + + // 3. blocked — split: if the rail carries a `restart-identity` action the // user can self-fix by re-verifying with a different document; otherwise // the only path is contact-support. const blocked = candidates.find((rail) => rail.status === 'blocked') @@ -160,7 +172,7 @@ export function deriveGate(state: CapabilityState, op: RailOperation, scope: Gat const requiresInfoRails = candidates.filter((rail) => rail.status === 'requires-info') - // 3. accept-tos + // 4. accept-tos const tosRail = requiresInfoRails.find((rail) => railActions(rail, actionByKey).some((action) => action.kind === 'accept-tos') ) @@ -174,7 +186,7 @@ export function deriveGate(state: CapabilityState, op: RailOperation, scope: Gat } } - // 4. fixable-rejection (Sumsub RFI / self-heal) + // 5. fixable-rejection (Sumsub RFI / self-heal) const fixableRail = requiresInfoRails.find((rail) => railActions(rail, actionByKey).some((action) => action.kind === 'sumsub') ) @@ -186,10 +198,6 @@ export function deriveGate(state: CapabilityState, op: RailOperation, scope: Gat } } - // 5. ready — per-op refinement wins over rail-level status - const hasReady = candidates.some((rail) => operationStatus(rail, op) === 'enabled') - if (hasReady) return { kind: 'ready' } - // 6. pending — BE is provisioning, no user action needed const hasPending = candidates.some((rail) => rail.status === 'pending') if (hasPending) return { kind: 'pending' } diff --git a/src/utils/sentry.utils.ts b/src/utils/sentry.utils.ts index bbfa21193..11b4547ee 100644 --- a/src/utils/sentry.utils.ts +++ b/src/utils/sentry.utils.ts @@ -24,23 +24,241 @@ const SKIP_REPORTING: Array<{ pattern: string | RegExp; statuses: number[] }> = ] /** - * URLs whose request body carries a card secret (PIN, etc). For these, the - * raw body is replaced with '[REDACTED]' before being attached to Sentry — - * any non-2xx, timeout, or network failure on /rain/cards/{id}/pin would - * otherwise ship the user's 4-digit PIN to Sentry as {"pin":"1234"}. - * (SKIP_REPORTING above filters the 429 rate-limit case; this catches the - * 400/401/403/409/500/502/504/timeout/network branches that still report.) + * URLs whose request OR response body carries sensitive data wholesale. + * For these, the body is replaced with '[REDACTED]' before being attached + * to Sentry — covers card secrets, KYC submissions, send-link passwords, + * auth credentials. */ -const BODY_SENSITIVE_URLS: RegExp[] = [/\/rain\/cards\/[^/]+\/pin(?:[/?]|$)/] +const BODY_SENSITIVE_URLS: RegExp[] = [ + // Card secrets — PIN, CVV, details + /\/rain\/cards\/[^/]+\/(?:pin|cvv|details)(?:[/?]|$)/, + // Card creation/update — Rain backend, holder PII + /\/rain\/cards(?:\?|$)/, + /\/rain\/cardholders/, + // Send-link passwords + /\/send-link\/(?:create|verify-password|claim|set-password)/, + /\/verify-password/, + // Auth — login, signup, password set/reset + /\/(?:login|signup|register|set-password|reset-password|change-password)/, + // KYC — Bridge, Sumsub, Manteca + /\/kyc\/(?:start|submit|update)/, + /\/bridge\/customers/, + /\/manteca\/(?:user|widgets)/, + /\/sumsub\/(?:applicant|token)/, +] + +/** + * Lowercased + underscore/hyphen-stripped field names whose values should + * be redacted recursively. Identity fields (userId, username, email, + * inviteCode) are intentionally NOT in this set — they're already in + * PostHog and Hugo wants them queryable in Sentry too. + * + * IMPORTANT: this is an EXACT-MATCH set. We deliberately do NOT + * substring-match because Peanut has first-class onchain addresses + * everywhere — `walletAddress`, `recipientAddress`, `tokenAddress`, + * `sdaAddress`, `depositAddress`, `destinationAddress`, `payerAddress`. + * Those are public chain data that MUST stay visible for debugging + * onchain flows. Substring-matching on `address` would clobber every + * one of them. Same for `pin`, `token`, `seed` — share names with + * non-sensitive concepts. + */ +const SENSITIVE_KEYS = new Set([ + // Passwords + secrets + 'password', + 'pwd', + 'passphrase', + 'secret', + 'secretkey', + 'apikey', + 'apitoken', + 'bearer', + 'authtoken', + 'jwt', + 'token', + 'sessiontoken', + 'refreshtoken', + 'accesstoken', + 'idtoken', + 'privatekey', + 'mnemonic', + 'seed', + 'seedphrase', + 'recoveryphrase', + // Card data + 'pan', + 'cardnumber', + 'cvv', + 'cvc', + 'securitycode', + 'cardpin', + 'pin', + 'cardholdername', + 'expirydate', + 'expirymonth', + 'expiryyear', + 'expmonth', + 'expyear', + // Government IDs (English + Bridge long-form) + 'ssn', + 'socialsecurity', + 'socialsecuritynumber', + 'taxid', + 'taxidentificationnumber', + 'tin', + 'dni', + 'cuit', + 'cuil', + 'rfc', + 'curp', + 'nif', + 'governmentid', + 'governmentidnumber', + 'documentnumber', + 'passport', + 'passportnumber', + 'driverslicense', + 'licensenumber', + 'idnumber', + 'nationalid', + 'nationalidnumber', + // Manteca (Spanish) + 'documento', + 'numerodocumento', + 'numerodedocumento', + // Bank account numbers + 'iban', + 'swift', + 'bic', + 'sortcode', + 'routingnumber', + 'accountnumber', + 'bankaccountnumber', + 'cbu', + 'cvu', + 'clabe', + // PII — names (English + Manteca Spanish) + 'firstname', + 'lastname', + 'fullname', + 'givenname', + 'familyname', + 'surname', + 'middlename', + 'mothername', + 'mothersmaidenname', + 'maidenname', + 'customerfirstname', + 'customerlastname', + 'nombre', + 'apellido', + // PII — address. NOTE: `address` alone is NOT here — onchain addresses + // (walletAddress, recipientAddress, etc.) must stay visible for + // debugging onchain flows. + 'streetaddress', + 'street1', + 'street2', + 'street3', + 'streetline1', + 'streetline2', + 'streetline3', + 'addressline1', + 'addressline2', + 'addressline3', + 'billingaddress', + 'homeaddress', + 'mailingaddress', + 'residentialaddress', + 'permanentaddress', + 'direccion', + 'domicilio', + 'postalcode', + 'zipcode', + 'zip', + 'postcode', + // PII — DOB / contact + 'dob', + 'dateofbirth', + 'birthdate', + 'birthday', + 'phonenumber', + 'mobilenumber', + 'telephone', + 'telefono', + // 2FA / OTP + 'otp', + 'verificationcode', + 'totpsecret', + 'twofactor', + 'twofactorsecret', +]) + +function normalizeKey(key: string): string { + return key.toLowerCase().replace(/[_-]/g, '') +} + +function isSensitiveKey(key: string): boolean { + return SENSITIVE_KEYS.has(normalizeKey(key)) +} + +function isSensitiveUrl(url: string | undefined): boolean { + if (!url) return false + return BODY_SENSITIVE_URLS.some((pattern) => pattern.test(url)) +} + +/** + * Recursively redacts sensitive keys in any object — applied to both + * request bodies AND response bodies before they ship to Sentry. + */ +export function scrubObject(value: unknown, depth = 0): unknown { + if (depth > 10) return '[REDACTED: max depth]' + if (value === null || value === undefined) return value + if (typeof value !== 'object') return value + if (Array.isArray(value)) return value.map((item) => scrubObject(item, depth + 1)) + // Prototype-pollution defense — two layers, both required: + // 1. `Object.create(null)` so `out` has no prototype to pollute. + // 2. `Object.defineProperty` with explicit descriptor instead of + // `out[key] = …`. The former is recognised by CodeQL's taint + // analysis as a sanitizer; the latter triggers + // js/prototype-polluting-assignment even when keys are validated + // because CodeQL can't prove the runtime check is complete. + // 3. Explicit skip of __proto__ / constructor / prototype — belt + // and braces; redundant with (1) but documents intent. + const out: Record = Object.create(null) + for (const [key, val] of Object.entries(value as Record)) { + if (key === '__proto__' || key === 'constructor' || key === 'prototype') continue + Object.defineProperty(out, key, { + value: isSensitiveKey(key) ? '[REDACTED]' : scrubObject(val, depth + 1), + writable: true, + enumerable: true, + configurable: true, + }) + } + return out +} export const sanitizeRequestBody = (url: string, body: BodyInit | null | undefined): BodyInit | string | null => { if (body == null) return null - for (const pattern of BODY_SENSITIVE_URLS) { - if (pattern.test(url)) return '[REDACTED]' + if (isSensitiveUrl(url)) return '[REDACTED: sensitive endpoint]' + // String bodies — try JSON parse, scrub, re-stringify; otherwise pass through. + if (typeof body === 'string') { + try { + return JSON.stringify(scrubObject(JSON.parse(body))) + } catch { + return body + } } return body } +/** + * Sanitize response bodies before they land in Sentry `extra`. Same + * URL + key-scrubbing as request bodies. + */ +export const sanitizeResponseBody = (url: string, body: unknown): unknown => { + if (isSensitiveUrl(url)) return '[REDACTED: sensitive endpoint]' + return scrubObject(body) +} + /** * Map URL → feature tag so Sentry issues can be filtered by product surface * without wrapping every call site. Add new entries here as features grow. @@ -88,7 +306,19 @@ const sanitizeHeaders = (headers: any): any => { if (!headers) return headers const sanitized = { ...headers } - const sensitiveHeaders = ['authorization', 'cookie', 'x-auth-token', 'api-key'] + const sensitiveHeaders = [ + 'authorization', + 'cookie', + 'set-cookie', + 'x-auth-token', + 'api-key', + 'x-api-key', + 'apikey', + 'md-api-key', // Manteca + 'x-app-token', // Sumsub + 'x-app-access-sig', + 'x-app-access-ts', + ] for (const key of Object.keys(sanitized)) { if (sensitiveHeaders.includes(key.toLowerCase())) { @@ -157,7 +387,7 @@ export const fetchWithSentry = async ( requestHeaders: sanitizeHeaders(options.headers || {}), requestBody: sanitizeRequestBody(url, options.body), status: response.status, - response: errorContent, + response: sanitizeResponseBody(url, errorContent), }, }) })
- Members-only access. Use this invite to open your wallet and start sending and receiving - money globally. -
{description}