Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
55 commits
Select commit Hold shift + click to select a range
89a23c6
fix(badges): sort a copy in BadgesRow instead of mutating the prop
innolope-dev Jul 16, 2026
fa8937e
fix(native): don't flag P0_TRANSFORMS pages as uncovered server routes
innolope-dev Jul 16, 2026
9aad003
fix(card): don't render '#null' for a waitlist entry without a position
innolope-dev Jul 16, 2026
3b8466e
test(card): derive the expected waitlist position from the active locale
innolope-dev Jul 17, 2026
877b2f9
Merge remote-tracking branch 'origin/main' into fix/physical-waitlist…
innolope-dev Jul 17, 2026
bf3516e
chore(types): add FE types for GET /notifications/admin/recent
innolope-dev Jul 17, 2026
c8ee8c7
feat(card): proof-of-address upload on stuck Rain applications
jjramirezn Jul 17, 2026
f9ab02d
Merge pull request #2446 from peanutprotocol/hotfix/rain-poa-upload
Hugo0 Jul 17, 2026
10ee160
fix(sentry): stop double-counting fetch failures, add missing DrawerT…
innolope-dev Jul 18, 2026
122ecc4
test(sentry): pin /invites/validate 400 suppression in fetchWithSentry
innolope-dev Jul 18, 2026
c77c738
fix(sentry): suppress Capacitor plugin-not-implemented iframe noise
innolope-dev Jul 18, 2026
00c97cc
fix(a11y): always give DrawerContent an accessible DialogTitle
innolope-dev Jul 18, 2026
b89a285
fix(sentry): match ignore patterns per field, not across concatenated…
innolope-dev Jul 18, 2026
9538152
test: restore console spy via try/finally in Drawer warning test
innolope-dev Jul 18, 2026
1ff12af
revert badge drawer change; instance fix ships in #2448
innolope-dev Jul 18, 2026
8362551
fix(sentry): drop plugin-not-implemented filter, keep per-field matching
innolope-dev Jul 18, 2026
7431f2c
content: publish latest to production (src/content → peanut-content@c…
abalinda Jul 20, 2026
4d23ef9
Merge pull request #2454 from peanutprotocol/content/publish-to-main-…
abalinda Jul 20, 2026
ac181ff
test(sentry): cover the ServiceUnavailableError ignore entry + note s…
innolope-dev Jul 21, 2026
bc0b6c6
fix: block crypto withdrawals below Rhino route minimums
abalinda Jul 21, 2026
c664efe
fix: key Tron minimum by the picker's 'tron' slug, not the numeric id
abalinda Jul 21, 2026
da303f6
fix: clear chain-scoped errors on destination change + registry-hones…
abalinda Jul 21, 2026
748f8a1
fix: compare the USD-pinned input directly — drop token-price scaling
abalinda Jul 21, 2026
d8a0709
refine: Rhino minimums only where Rhino is involved
abalinda Jul 21, 2026
1f568d8
hotfix: fund Manteca QR payments to per-rail wallets (AR vs non-AR)
jjramirezn Jul 21, 2026
28aac8c
refine: drop the amount-step heads-up card — block only at network se…
abalinda Jul 21, 2026
d1a4751
feat(security): biometric app lock for the native app
innolope-dev Jul 21, 2026
c0fbae4
feat(security): report-only CSP with a script-src allow-list
innolope-dev Jul 21, 2026
969d498
feat(security): prove a fresh passkey assertion on sensitive actions
innolope-dev Jul 21, 2026
331f206
fix(review): preserve DSN protocol and path in the CSP report URI
innolope-dev Jul 21, 2026
e4db902
fix(review): make the app lock a boundary, not an overlay
innolope-dev Jul 21, 2026
273044e
Merge pull request #2460 from peanutprotocol/hotfix/manteca-qr-wallet…
jjramirezn Jul 21, 2026
b52b2bc
fix(review): route rainRequest auth through apiFetch
innolope-dev Jul 22, 2026
e3b0778
feat(review): deliver CSP reports via report-to as well as report-uri
innolope-dev Jul 22, 2026
2131b43
docs(review): state the app lock's full fail-open surface honestly
innolope-dev Jul 22, 2026
fc2114a
Merge pull request #2435 from peanutprotocol/fix/badges-row-impure-sort
kushagrasarathe Jul 22, 2026
d3ff4be
Merge pull request #2437 from peanutprotocol/fix/physical-waitlist-nu…
kushagrasarathe Jul 22, 2026
af7c6e2
Merge pull request #2449 from peanutprotocol/test/invites-validate-40…
kushagrasarathe Jul 22, 2026
5d5277f
Merge pull request #2451 from peanutprotocol/fix/dialog-title-a11y
kushagrasarathe Jul 22, 2026
1828d76
Merge pull request #2445 from peanutprotocol/chore/fe-types-notificat…
kushagrasarathe Jul 22, 2026
036ab50
Merge pull request #2436 from peanutprotocol/fix/native-build-transfo…
kushagrasarathe Jul 22, 2026
031822f
Merge pull request #2450 from peanutprotocol/fix/suppress-ios-iframe-…
kushagrasarathe Jul 22, 2026
dfc4f2e
Merge pull request #2448 from peanutprotocol/fix/sentry-client-noise
kushagrasarathe Jul 22, 2026
7b983c0
Merge pull request #2458 from peanutprotocol/fix/rhino-min-withdrawal…
jjramirezn Jul 22, 2026
4fc04be
fix(native): route push notification taps inside the app
innolope-dev Jul 22, 2026
cf4ba72
fix(native): harden deep-link mapping against malformed and reserved …
innolope-dev Jul 22, 2026
86e0766
fix(native): buffer cold-start push clicks, open external links in br…
innolope-dev Jul 22, 2026
c9a0d54
fix(notifications): surface native OneSignal failures in Sentry
innolope-dev Jul 22, 2026
e14cddb
Merge pull request #2461 from peanutprotocol/feat/native-app-lock
kushagrasarathe Jul 22, 2026
24c0e49
Merge pull request #2462 from peanutprotocol/feat/csp-report-only
kushagrasarathe Jul 22, 2026
d4e5410
Merge pull request #2463 from peanutprotocol/feat/step-up-auth
kushagrasarathe Jul 22, 2026
9112101
Merge pull request #2473 from peanutprotocol/fix/native-onesignal-sen…
kushagrasarathe Jul 22, 2026
5fd5969
Merge pull request #2470 from peanutprotocol/fix/native-push-deeplink…
kushagrasarathe Jul 22, 2026
b3c680c
merge: main into mobile-release (pre-build sync)
innolope-dev Jul 22, 2026
2e241a3
merge: localization + eslint cleanup (#2447) into mobile-release
innolope-dev Jul 22, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,14 @@
<data android:pathPrefix="/withdraw/" />
<data android:path="/receipt" />
<data android:pathPrefix="/receipt/" />
<data android:path="/history" />
<data android:pathPrefix="/history/" />
<data android:path="/rewards" />
<data android:pathPrefix="/rewards/" />
<data android:path="/badges" />
<data android:pathPrefix="/badges/" />
<data android:path="/profile" />
<data android:pathPrefix="/profile/" />
</intent-filter>

</activity>
Expand All @@ -78,6 +86,15 @@
<meta-data
android:name="asset_statements"
android:resource="@string/capacitor_passkey_asset_statements" />

<!-- Don't let OneSignal fire its own ACTION_VIEW for a notification's
launch URL. The tap opens the app and useNativePlugins routes on the
deep link in-app instead — which covers destinations outside the App
Links filter above, doesn't depend on link verification, and keeps
iOS and Android on the same code path. -->
<meta-data
android:name="com.onesignal.suppressLaunchURLs"
android:value="true" />
</application>

<!-- Phone-only: require a telephony radio so Play filters out Wi-Fi-only
Expand Down
38 changes: 36 additions & 2 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 <style> attributes (built into Next), not DOM props.
'react/no-unknown-property': ['error', { ignore: ['jsx', 'global'] }],

// Ban barrel imports — see BANNED_BARREL_PATHS above.
'no-restricted-imports': [
Expand Down Expand Up @@ -162,8 +164,37 @@ module.exports = [
{
// require() inside test bodies is the Jest idiom for reading mocks after
// jest.mock()/resetModules(); hoisting them to imports changes semantics.
files: ['src/**/__tests__/**/*.{ts,tsx}', 'src/**/*.test.{ts,tsx}'],
rules: { '@typescript-eslint/no-require-imports': 'off' },
// no-img-element: these files mock next/image down to a raw <img>.
// no-explicit-any: mocks and partial fixtures legitimately cast through
// `any` — production code keeps the ban.
files: ['src/**/__tests__/**/*.{ts,tsx}', 'src/**/*.test.{ts,tsx}', 'src/**/__mocks__/**/*.{ts,tsx}'],
rules: {
'@typescript-eslint/no-require-imports': 'off',
'@next/next/no-img-element': 'off',
'@typescript-eslint/no-explicit-any': 'off',
},
},
{
// Dev-only tooling: /dev pages, the window.debug console cheats, and the
// InvitesGraph debug visualization. The cheat API is intrinsically dynamic
// and d3/force-graph mutate node objects at runtime — typing them buys no
// user-facing safety. Production code keeps the any ban.
files: ['src/app/(mobile-ui)/dev/**', 'src/context/PeanutDebug.tsx', 'src/components/Global/InvitesGraph/**'],
rules: { '@typescript-eslint/no-explicit-any': 'off' },
},
{
// OG images render through Satori (next/og ImageResponse), which supports
// only a subset of HTML/CSS and cannot render next/image — raw <img> with
// explicit width/height is the required form here, not an oversight.
files: ['src/components/og/**', 'src/app/api/og/**'],
rules: { '@next/next/no-img-element': 'off' },
},
{
// Rasterized to PNG by html-to-image (see share-asset/captureShareAsset.ts).
// next/image's lazy loading and wrapper markup break the capture — the same
// class of bug as the runtime <canvas> that file already documents.
files: ['src/components/Card/share-asset/**', 'src/components/Global/ImageGeneration/**'],
rules: { '@next/next/no-img-element': 'off' },
},
{
// Localization guard: product-UI copy must come from next-intl, not JSX
Expand Down Expand Up @@ -192,6 +223,9 @@ module.exports = [
'src/components/Global/{PeanutLoading,Icons,Badges}/**',
// InvitesGraph is a /dev-only debug visualization, not user-facing UI.
'src/components/Global/InvitesGraph/**',
// Hidden support tool — never linked in-app; support DMs the URL to
// affected users, so the copy stays English-only.
'src/app/(mobile-ui)/fix-card-signature/**',
],
rules: {
'react/jsx-no-literals': [
Expand Down
6 changes: 6 additions & 0 deletions ios/App/App/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,12 @@
<string>Peanut may use your location to help verify your identity and prevent fraud during account setup and support.</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<!-- Stop OneSignal calling openURL for a notification's launch URL. iOS won't
re-enter this app for its own universal link, so without this a tapped push
bounces the user out to Safari. useNativePlugins routes the deep link in-app
from the click listener instead. -->
<key>OneSignal_suppress_launch_urls</key>
<true/>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
Expand Down
100 changes: 99 additions & 1 deletion next.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,97 @@ const withBundleAnalyzer =

const redirectsConfig = require('./redirects.json')

/**
* Sentry's CSP-report ingest endpoint, derived from the browser DSN
* (`<protocol>://<publicKey>@<host><path>/<projectId>`). 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 { protocol, host, username, pathname } = new URL(dsn)
const segments = pathname.split('/').filter(Boolean)
const projectId = segments.pop()
if (!host || !username || !projectId) return null
const prefix = segments.length ? `/${segments.join('/')}` : ''
return `${protocol}//${host}${prefix}/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'",
]
// 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 {
Expand Down Expand Up @@ -186,7 +277,14 @@ 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 <base>, 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() },
...reportingEndpointsHeader(),
{ key: 'X-Content-Type-Options', value: 'nosniff' },
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
],
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@
"@testing-library/jest-dom": "^6.4.2",
"@testing-library/react": "^16.1.0",
"@types/canvas-confetti": "^1.9.0",
"@types/d3-force": "^3.0.10",
"@types/jest": "^29.5.12",
"@types/js-cookie": "^3.0.6",
"@types/node": "20.4.2",
Expand Down
8 changes: 8 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

57 changes: 57 additions & 0 deletions scripts/__tests__/native-build-scan.test.js
Original file line number Diff line number Diff line change
@@ -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([])
})
})
12 changes: 10 additions & 2 deletions scripts/native-build.js
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ export default function RootRedirect() {
{
path: '(mobile-ui)/claim/page.tsx',
// strip generateMetadata + force-dynamic, keep component render (SEO irrelevant in native)
replacement: `import { Claim } from '@/components'
replacement: `import { Claim } from '@/components/Claim/Claim'

export default function ClaimPage() {
return <Claim />
Expand Down Expand Up @@ -253,6 +253,14 @@ function isCoveredByDisableList(relPath) {
return P0_TRANSFORMS.some((item) => relPath === item.path)
}

// 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
Expand All @@ -263,7 +271,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
Expand Down
19 changes: 19 additions & 0 deletions sentry.utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import type { ErrorEvent } from '@sentry/nextjs'
import { shouldIgnoreError } from './sentry.utils'

function eventWith(partial: { message?: string; type?: string; value?: string }): ErrorEvent {
return {
message: partial.message,
exception: { values: [{ type: partial.type, value: partial.value }] },
} as unknown as ErrorEvent
}

describe('shouldIgnoreError — alreadyReported (fetchWithSentry wrapper)', () => {
it('ignores a re-thrown ServiceUnavailableError (already captured at the fetch site)', () => {
expect(shouldIgnoreError(eventWith({ type: 'ServiceUnavailableError', value: 'upstream 503' }))).toBe(true)
})

it('does not ignore an unrelated application error', () => {
expect(shouldIgnoreError(eventWith({ type: 'TypeError', value: 'x is not a function' }))).toBe(false)
})
})
15 changes: 13 additions & 2 deletions sentry.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,15 @@ const IGNORED_ERRORS = {
// Third-party scripts we don't control
thirdParty: ['googletagmanager', 'gtag', 'analytics', 'hotjar', 'clarity', 'intercom', 'crisp'],

// fetchWithSentry wrapper errors: the underlying timeout/network/HTTP
// failure is already captured at the fetch site with full context, so the
// re-thrown ServiceUnavailableError bubbling to global handlers (or being
// console.error'd by a consumer) would only double-count it (PEANUT-UI-QDJ).
// Substring-matching this pattern is safe only because ServiceUnavailableError
// is our own internal fetchWithSentry wrapper name, not a generic string that
// could appear in an unrelated third-party error message.
alreadyReported: ['ServiceUnavailableError'],

// Third-party SDK internal errors (not actionable)
thirdPartySdkErrors: [
'IndexedDB:Set:InternalError', // Vercel Analytics storage - fails in private browsing, not actionable
Expand All @@ -58,12 +67,14 @@ export function shouldIgnoreError(event: ErrorEvent): boolean {
const exceptionType = event.exception?.values?.[0]?.type || ''
const culprit = (event as any).culprit || ''

const searchText = `${message} ${exceptionValue} ${exceptionType} ${culprit}`.toLowerCase()
// Match each field independently — concatenating them would let a pattern
// match across unrelated fields and suppress a legitimate event.
const searchTexts = [message, exceptionValue, exceptionType, culprit]

// Check all ignore patterns
for (const patterns of Object.values(IGNORED_ERRORS)) {
for (const pattern of patterns) {
if (searchText.includes(pattern.toLowerCase())) {
if (searchTexts.some((text) => text.toLowerCase().includes(pattern.toLowerCase()))) {
return true
}
}
Expand Down
Loading
Loading