Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
69 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
e0c0b64
Merge remote-tracking branch 'origin/main' into chore/backmerge-main-…
kushagrasarathe Jul 22, 2026
6c463f5
fix(withdraw): show non-EVM token selection in the selector button
abalinda Jul 23, 2026
fcf7bb3
fix(withdraw): move the no-amount redirect out of render
abalinda Jul 23, 2026
209d011
chore: revert unintended src/content submodule bump (restore main's p…
abalinda Jul 23, 2026
6d473c7
fix(withdraw): complete the user-facing charge on collateral-routed w…
abalinda Jul 22, 2026
fcad265
fix(withdraw): only record mixed same-chain spends with a mined receipt
abalinda Jul 22, 2026
255b083
test(withdraw): type the confirm-flow test mocks (keep new code eslin…
abalinda Jul 22, 2026
1c728a8
test(withdraw): pin cross-chain mixed-without-receipt keeps recording…
abalinda Jul 22, 2026
2b967a5
review: purge two more skip-recordPayment comments, pin mixed toleran…
abalinda Jul 22, 2026
0a44cd6
fix(withdraw): exact-match fallback for non-EVM token selection (review)
abalinda Jul 23, 2026
ed3021a
Merge pull request #2491 from peanutprotocol/fix/withdraw-collateral-…
abalinda Jul 23, 2026
03ba3bd
Merge pull request #2490 from peanutprotocol/hotfix/token-selector-no…
jjramirezn Jul 23, 2026
4d5f2f2
feat(footer): add Squirrel Labs Ltd legal-entity line
0xkkonrad Jul 24, 2026
2e65a2e
Merge pull request #2499 from peanutprotocol/hotfix/footer-legal-entity
Hugo0 Jul 24, 2026
eeab923
Merge remote-tracking branch 'origin/dev' into chore/backmerge-main-t…
jjramirezn Jul 24, 2026
3852647
Merge remote-tracking branch 'origin/main' into chore/backmerge-main-…
jjramirezn Jul 24, 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
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
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([])
})
})
10 changes: 9 additions & 1 deletion scripts/native-build.js
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,14 @@ function isCoveredByDisableList(relPath) {
})
}

// P0_TRANSFORMS files are replaced with static-export-safe stubs before `next
// build`, so their server-only exports (generateMetadata, force-dynamic) never
// reach the export — the scan must not flag them.
function isHandledByTransform(relPath) {
const normalized = relPath.split(path.sep).join('/')
return P0_TRANSFORMS.some((t) => t.path === normalized)
}

function detectUncoveredServerRoutes(dir = APP_DIR, found = []) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (entry.name.includes('.disabled') || entry.name.startsWith('_')) continue
Expand All @@ -258,7 +266,7 @@ function detectUncoveredServerRoutes(dir = APP_DIR, found = []) {
detectUncoveredServerRoutes(full, found)
continue
}
if (isCoveredByDisableList(rel)) continue
if (isCoveredByDisableList(rel) || isHandledByTransform(rel)) continue
if (entry.name === 'route.ts' || entry.name === 'route.js') {
found.push({ rel, reason: 'route handler (cannot be statically exported)' })
continue
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