Skip to content

Feat/qr sheets#1394

Merged
Hugo0 merged 8 commits intopeanut-wallet-devfrom
feat/qr-sheets
Nov 3, 2025
Merged

Feat/qr sheets#1394
Hugo0 merged 8 commits intopeanut-wallet-devfrom
feat/qr-sheets

Conversation

@Hugo0
Copy link
Contributor

@Hugo0 Hugo0 commented Nov 3, 2025

User-Tied QR Sheets

Branch: feat/qr-sheets

Physical sticker sheets with unique QR codes. First scan by authenticated user permanently claims the QR and redirects to their invite link.

Changes

Backend (peanut-api-ts)

  • Added Redirect table: inputPath, targetUrl, claimedByUserId, claimedAt
  • GET /qr/:code - Check claim status (public)
  • POST /qr/:code/claim - Claim QR (authenticated, first-scan-wins)
  • Redirects to user invite: https://peanut.me/invite?code={USERNAME}INVITESYOU
  • Supports arbitrary external URLs (e.g., https://google.com)

Frontend (peanut-ui)

  • /qr/[code] - Claim page with 1.5s hold-to-claim button (progressive shake/haptic)
  • /qr/[code]/success - Success page with confetti and shareable QR
  • useHoldToClaim hook - Reusable hold pattern
  • useRedirectQrStatus hook - TanStack Query for status fetching
  • QR scanner handles peanut.me/qr/* URLs
  • Invite page redirects logged-in users (with app access) to inviter's profile
  • External redirect support (detects origin, uses full navigation)
image

@vercel
Copy link

vercel bot commented Nov 3, 2025

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Preview Comments Updated (UTC)
peanut-ui (peanut-wallet-staging) Ready Ready Preview Comment Nov 3, 2025 10:04pm
peanut-wallet Ready Ready Preview Comment Nov 3, 2025 10:04pm

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Nov 3, 2025

Walkthrough

This PR introduces a complete QR code claiming flow with new pages and hooks, establishes centralized routing constants with reserved-route guards, enhances DirectSendQR with path normalization and redirect logic, and updates various UI components and configurations including modal styling, share functionality, and SDK version pinning.

Changes

Cohort / File(s) Summary
QR Claiming Flow
src/app/(mobile-ui)/qr/[code]/page.tsx, src/app/(mobile-ui)/qr/[code]/success/page.tsx
Adds client-side claim page with route params, useAuth hook, useRedirectQrStatus fetch, conditional redirects (safe internal and validated external), hold-to-claim interaction via useHoldToClaim, backend POST to claim endpoint with JWT, and error states. Success page fetches QR details, displays confetti animation, renders QR code, and provides share/copy actions with native share dialog support.
Claim & Hold Interaction Hooks
src/hooks/useHoldToClaim.ts, src/hooks/useRedirectQrStatus.ts
useHoldToClaim: new hook with hold progress tracking, shake animation state, haptic vibration patterns, keyboard/pointer event handling, and onComplete callback. useRedirectQrStatus: React-Query hook fetching QR status from backend with fresh-data staleTime and refetchOnMount enabled.
Centralized Routing Configuration
src/constants/routes.ts, src/constants/index.ts
routes.ts: new module exporting DEDICATED_ROUTES, STATIC_REDIRECT_ROUTES, RESERVED_ROUTES, PUBLIC_ROUTES, PUBLIC_ROUTES_REGEX, MIDDLEWARE_ROUTES, isReservedRoute() and isPublicRoute() helpers. constants/index.ts: re-exports all from routes module.
Routing Guards & Middleware
src/app/[...recipient]/page.tsx, src/app/(mobile-ui)/layout.tsx, src/middleware.ts
[...recipient]/page.tsx: adds notFound() guard for reserved routes in both generateMetadata and page component. layout.tsx: replaces local regex with imported PUBLIC_ROUTES_REGEX. middleware.ts: adds '/qr/:path\*' to matcher array.
DirectSendQR QR Recognition Enhancement
src/components/Global/DirectSendQR/index.tsx, src/components/Global/DirectSendQR/utils.ts
index.tsx: adds normalization of extracted path (strip protocol, www, domain) and special handling for /qr/ paths (extract code, fetch status, use redirectUrl if claimed or route to /qr/{code} if unclaimed). utils.ts: normalizes input and base URL with lowercasing and prefix removal; broadens Peanut URL validation to support localhost and peanut.me/.
UI Component Updates
src/components/Global/Modal/index.tsx, src/components/Global/ActionModal/index.tsx, src/components/Global/ShareButton/index.tsx, src/components/Profile/components/PublicProfile.tsx
Modal: adds border-0 class to Dialog.Panel. ActionModal: adds !border-0 modifier to classWrap. ShareButton: always copy to clipboard first, then attempt Web Share API if available; enhanced error handling with AbortError suppression. PublicProfile: adds styling (rounded-md bg-grey-4/25 px-3 py-2) to public activity notice container.
Invite & Auth Flow
src/components/Invites/InvitesPage.tsx, src/hooks/query/user.ts
InvitesPage.tsx: adds useEffect to redirect logged-in users with app access and valid inviteCodeData to inviter's profile. user.ts: extends hitUserMetric call to include deviceType in login event payload.
Configuration & Documentation
public/game/peanut-game.html, public/onesignal/OneSignalSDKWorker.js, src/app/layout.tsx, knip.json, src/utils/general.utils.ts
peanut-game.html: pins Facebook SDK to v2.2 and adds version option to FB.init. OneSignalSDKWorker.js: expands version-pinning rationale comment. layout.tsx: adds note on gtag.js version-pinning limitation. knip.json: pins schema URL to v5.37.1. general.utils.ts: adds JSDoc noting generateInviteCodeSuffix logic is duplicated in backend.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

  • src/app/(mobile-ui)/qr/[code]/page.tsx: Verify claim flow logic, unauthenticated redirects to /setup, safe redirect handling (domain validation, sanitized paths), and JWT token generation for backend claim endpoint.
  • src/hooks/useHoldToClaim.ts: Review interaction state machine, vibration/haptic patterns, shake intensity progression logic, keyboard/pointer accessibility, and cleanup on unmount.
  • src/components/Global/DirectSendQR/index.tsx: Verify QR code extraction, API status fetch, conditional redirect routing (claimed vs. unclaimed paths), and fallback behavior on fetch failures.
  • src/constants/routes.ts: Ensure RESERVED_ROUTES, PUBLIC_ROUTES_REGEX definitions are consistent across layout, middleware, and catch-all routing; verify isReservedRoute() and isPublicRoute() helper correctness.
  • src/app/[...recipient]/page.tsx: Confirm notFound() guard placement in both generateMetadata and page component prevents reserved routes from matching catch-all.
  • src/components/Global/ShareButton/index.tsx: Validate clipboard-first approach, Web Share API fallback, and error handling (AbortError suppression vs. Sentry logging).

Possibly related PRs

  • PR #1324: Modifies QR recognition logic in DirectSendQR/utils.ts with URL normalization and pattern broadening for Peanut URLs.
  • PR #765: Changes DirectSendQR index.tsx QR-processing flow and how redirect URL is determined and routed.
  • PR #1311: Updates public-path detection in layout.tsx with reserved-route guards, closely aligning with main PR's routing centralization.

Suggested reviewers

  • jjramirezn
  • kushagrasarathe

Pre-merge checks and finishing touches

❌ Failed checks (1 inconclusive)
Check name Status Explanation Resolution
Title check ❓ Inconclusive The title 'Feat/qr sheets' is vague and generic; it does not clearly convey what the changeset accomplishes. Terms like 'feat' and 'qr sheets' lack descriptive detail about the primary change. Consider a more descriptive title such as 'Add QR code claiming flow with hold-to-claim interaction' or 'Implement physical sticker QR claiming with redirect support'.
✅ Passed checks (1 passed)
Check name Status Explanation
Description check ✅ Passed The description is well-structured and clearly related to the changeset. It outlines backend changes (Redirect table, QR endpoints), frontend changes (claim/success pages, hooks), and includes screenshots and technical notes.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/qr-sheets

📜 Recent review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between ecad685 and c371a62.

📒 Files selected for processing (1)
  • src/app/(mobile-ui)/qr/[code]/page.tsx (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/app/(mobile-ui)/qr/[code]/page.tsx
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Deploy-Preview

Comment @coderabbitai help to get the list of available commands and usage tips.

Comment on lines +10 to +27
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
submodules: true
- uses: pnpm/action-setup@v4
with:
version: 9
- name: Install Vercel CLI
run: pnpm add --global vercel@latest
- name: Link to Project
run: vercel link --yes --project=peanut-wallet --token=${{ secrets.VERCEL_TOKEN }}
- name: Pull Vercel Environment Information
run: vercel pull --yes --environment=preview --token=${{ secrets.VERCEL_TOKEN }}
- name: Build Project Artifacts
run: vercel build --target=preview --token=${{ secrets.VERCEL_TOKEN }}
- name: Deploy Project Artifacts to Vercel
run: vercel deploy --prebuilt --target=preview --token=${{ secrets.VERCEL_TOKEN }}

Check warning

Code scanning / CodeQL

Workflow does not contain permissions Medium

Actions job or workflow does not limit the permissions of the GITHUB_TOKEN. Consider setting an explicit permissions block, using the following as a minimal starting point: {contents: read}

Copilot Autofix

AI 5 months ago

The best way to fix this problem is to explicitly set the GITHUB_TOKEN permissions for the workflow or for the job. Based on the steps involved—checking out code and running build/publish via Vercel—the required minimal permission is likely contents: read. You should add a permissions: block at the top level of the workflow (before jobs:) to cover all jobs, or at the job level if only certain jobs require those permissions. In this case, placing it at the root level is cleanest. The block should look exactly like:

permissions:
  contents: read

This change is to be added above the jobs: section in .github/workflows/preview.yaml.

Suggested changeset 1
.github/workflows/preview.yaml

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/.github/workflows/preview.yaml b/.github/workflows/preview.yaml
--- a/.github/workflows/preview.yaml
+++ b/.github/workflows/preview.yaml
@@ -5,6 +5,8 @@
             - peanut-wallet
             - peanut-wallet-dev
 
+permissions:
+  contents: read
 jobs:
     Deploy-Preview:
         runs-on: ubuntu-latest
EOF
@@ -5,6 +5,8 @@
- peanut-wallet
- peanut-wallet-dev

permissions:
contents: read
jobs:
Deploy-Preview:
runs-on: ubuntu-latest
Copilot is powered by AI and may make mistakes. Always verify output.
- uses: actions/checkout@v4
with:
submodules: true
- uses: pnpm/action-setup@v4

Check warning

Code scanning / CodeQL

Unpinned tag for a non-immutable Action in workflow Medium

Unpinned 3rd party Action 'Preview deploy' step
Uses Step
uses 'pnpm/action-setup' with ref 'v4', not a pinned commit hash

const vapidKeys = webpush.generateVAPIDKeys()

console.log('NEXT_PUBLIC_VAPID_PUBLIC_KEY=' + vapidKeys.publicKey)

Check failure

Code scanning / CodeQL

Clear-text logging of sensitive information High

This logs sensitive data returned by
an access to vapidKeys
as clear text.

Copilot Autofix

AI 5 months ago

To fix the issue, we should prevent the script from logging (outputting) the VAPID private key in clear text. The line logging the public key is acceptable since public keys are not sensitive. The best approach is to remove or comment out the line that logs the private key, possibly replacing it with a message instructing the user to handle the key securely. If output of the private key is required for configuration, consider writing it to a protected file with appropriate access permissions, not to the console.

Changes needed:

  • In scripts/generate.mjs, remove (or comment out) the line logging the private key to the console (console.log('VAPID_PRIVATE_KEY=' + vapidKeys.privateKey)).
  • Optionally, add a comment or log message reminding users to save the private key securely.

Suggested changeset 1
scripts/generate.mjs

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/scripts/generate.mjs b/scripts/generate.mjs
--- a/scripts/generate.mjs
+++ b/scripts/generate.mjs
@@ -4,4 +4,3 @@
 const vapidKeys = webpush.generateVAPIDKeys()
 
 console.log('NEXT_PUBLIC_VAPID_PUBLIC_KEY=' + vapidKeys.publicKey)
-console.log('VAPID_PRIVATE_KEY=' + vapidKeys.privateKey)
EOF
@@ -4,4 +4,3 @@
const vapidKeys = webpush.generateVAPIDKeys()

console.log('NEXT_PUBLIC_VAPID_PUBLIC_KEY=' + vapidKeys.publicKey)
console.log('VAPID_PRIVATE_KEY=' + vapidKeys.privateKey)
Copilot is powered by AI and may make mistakes. Always verify output.
const vapidKeys = webpush.generateVAPIDKeys()

console.log('NEXT_PUBLIC_VAPID_PUBLIC_KEY=' + vapidKeys.publicKey)
console.log('VAPID_PRIVATE_KEY=' + vapidKeys.privateKey)

Check failure

Code scanning / CodeQL

Clear-text logging of sensitive information High

This logs sensitive data returned by
an access to vapidKeys
as clear text.

Copilot Autofix

AI 5 months ago

To fix this vulnerability, avoid printing the VAPID private key directly to the console. Instead, guide the user to securely store these values, such as by writing the keys to a local .env file (with clear file permission instructions) or another secure method suited for secret distribution. For this fix, the script will write both the public and private keys to a .env file (or another suitable filename) and inform the user (via the console) that the secrets have been written, without displaying the cleartext private key. This removes risks from console output while preserving usability.

Specific steps:

  • Add the fs module import for file writing.
  • Write both environment variable assignments (NEXT_PUBLIC_VAPID_PUBLIC_KEY=... and VAPID_PRIVATE_KEY=...) to a .env.generated file (to avoid overwriting the user's main .env file).
  • Remove the logging of the private key to the console.
  • Add a console.log statement to inform the user where the secrets have been written, without showing the key itself.
Suggested changeset 1
scripts/generate.mjs

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/scripts/generate.mjs b/scripts/generate.mjs
--- a/scripts/generate.mjs
+++ b/scripts/generate.mjs
@@ -1,7 +1,14 @@
 // Generates VAPID key pair for web push notifications and outputs them as environment variables
 import webpush from 'web-push'
+import fs from 'fs'
 
 const vapidKeys = webpush.generateVAPIDKeys()
 
-console.log('NEXT_PUBLIC_VAPID_PUBLIC_KEY=' + vapidKeys.publicKey)
-console.log('VAPID_PRIVATE_KEY=' + vapidKeys.privateKey)
+// Write environment variables to a .env.generated file (do NOT log sensitive data to console)
+fs.writeFileSync(
+  '.env.generated',
+ `NEXT_PUBLIC_VAPID_PUBLIC_KEY=${vapidKeys.publicKey}\nVAPID_PRIVATE_KEY=${vapidKeys.privateKey}\n`,
+ { encoding: 'utf-8', flag: 'w', mode: 0o600 }
+);
+
+console.log('VAPID keys generated and written to .env.generated. Secure this file and add variables to your environment as needed.');
EOF
@@ -1,7 +1,14 @@
// Generates VAPID key pair for web push notifications and outputs them as environment variables
import webpush from 'web-push'
import fs from 'fs'

const vapidKeys = webpush.generateVAPIDKeys()

console.log('NEXT_PUBLIC_VAPID_PUBLIC_KEY=' + vapidKeys.publicKey)
console.log('VAPID_PRIVATE_KEY=' + vapidKeys.privateKey)
// Write environment variables to a .env.generated file (do NOT log sensitive data to console)
fs.writeFileSync(
'.env.generated',
`NEXT_PUBLIC_VAPID_PUBLIC_KEY=${vapidKeys.publicKey}\nVAPID_PRIVATE_KEY=${vapidKeys.privateKey}\n`,
{ encoding: 'utf-8', flag: 'w', mode: 0o600 }
);

console.log('VAPID keys generated and written to .env.generated. Secure this file and add variables to your environment as needed.');
Copilot is powered by AI and may make mistakes. Always verify output.

return data
} catch (error) {
console.error(`Error creating external account for ${customerId}:`, error)

Check failure

Code scanning / CodeQL

Use of externally-controlled format string High

Format string depends on a
user-provided value
.

Copilot Autofix

AI 5 months ago

The best fix is to avoid embedding the external value customerId directly within the template literal for use as the primary message in console.error. Instead, pass a constant format string and provide the potentially untrusted value as a separate argument. This prevents any possibility of confusion caused by malicious or malformed values in customerId from impacting the structure of the log message.

Specifically, in src/app/actions/external-accounts.ts, on line 37, change

console.error(`Error creating external account for ${customerId}:`, error)

to

console.error('Error creating external account for %s:', customerId, error)

No additional imports or method definitions are required.


Suggested changeset 1
src/app/actions/external-accounts.ts

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/src/app/actions/external-accounts.ts b/src/app/actions/external-accounts.ts
--- a/src/app/actions/external-accounts.ts
+++ b/src/app/actions/external-accounts.ts
@@ -34,7 +34,7 @@
 
         return data
     } catch (error) {
-        console.error(`Error creating external account for ${customerId}:`, error)
+        console.error('Error creating external account for %s:', customerId, error)
         if (error instanceof Error) {
             return { error: error.message }
         }
EOF
@@ -34,7 +34,7 @@

return data
} catch (error) {
console.error(`Error creating external account for ${customerId}:`, error)
console.error('Error creating external account for %s:', customerId, error)
if (error instanceof Error) {
return { error: error.message }
}
Copilot is powered by AI and may make mistakes. Always verify output.

return data
} catch (error) {
console.error(`Error creating external account for ${customerId}:`, error)

Check warning

Code scanning / CodeQL

Log injection Medium

Log entry depends on a
user-provided value
.

Copilot Autofix

AI 5 months ago

To fix the problem, we should sanitize customerId before logging it. Specifically, remove any newline characters (\n and \r) that could be used to forge new log entries. We should also clearly delimit user input in the log entry, so it cannot be confused with the log format itself.

Changes:

  • Line 37 needs to change so that customerId is sanitized before being included in the log message.
  • This can be done by using customerId.replace(/\r?\n/g, "") (or /\n|\r/g), inline within the log statement or assigned to a variable for clarity.
  • No new dependencies need to be imported for this fix; JS string methods suffice.

Suggested changeset 1
src/app/actions/external-accounts.ts

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/src/app/actions/external-accounts.ts b/src/app/actions/external-accounts.ts
--- a/src/app/actions/external-accounts.ts
+++ b/src/app/actions/external-accounts.ts
@@ -34,7 +34,8 @@
 
         return data
     } catch (error) {
-        console.error(`Error creating external account for ${customerId}:`, error)
+        const safeCustomerId = customerId.replace(/[\r\n]/g, "");
+        console.error(`Error creating external account for [${safeCustomerId}]:`, error);
         if (error instanceof Error) {
             return { error: error.message }
         }
EOF
@@ -34,7 +34,8 @@

return data
} catch (error) {
console.error(`Error creating external account for ${customerId}:`, error)
const safeCustomerId = customerId.replace(/[\r\n]/g, "");
console.error(`Error creating external account for [${safeCustomerId}]:`, error);
if (error instanceof Error) {
return { error: error.message }
}
Copilot is powered by AI and may make mistakes. Always verify output.

return data
} catch (error) {
console.error(`Error creating external account for ${customerId}:`, error)

Check warning

Code scanning / CodeQL

Log injection Medium

Log entry depends on a
user-provided value
.

Copilot Autofix

AI 5 months ago

To fix this issue, sanitize the customerId prior to logging it. For plain text logs, this means removing line breaks and potentially other control characters. The best way is to preprocess customerId so that it cannot introduce new log entries or break the log format. Specifically, in createBridgeExternalAccountForGuest, before logging, replace any \n or \r characters with an empty string (using .replace(/\r?\n/g, '')). Optionally, clarify in the log entry that customerId is user-controlled, but the essential fix is the sanitization.
You'll need to update line 37 in src/app/actions/external-accounts.ts to sanitize customerId before logging. No extra imports are needed for this basic replacement.


Suggested changeset 1
src/app/actions/external-accounts.ts

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/src/app/actions/external-accounts.ts b/src/app/actions/external-accounts.ts
--- a/src/app/actions/external-accounts.ts
+++ b/src/app/actions/external-accounts.ts
@@ -34,7 +34,8 @@
 
         return data
     } catch (error) {
-        console.error(`Error creating external account for ${customerId}:`, error)
+        const sanitizedCustomerId = customerId.replace(/\r?\n/g, "");
+        console.error(`Error creating external account for customerId="${sanitizedCustomerId}":`, error)
         if (error instanceof Error) {
             return { error: error.message }
         }
EOF
@@ -34,7 +34,8 @@

return data
} catch (error) {
console.error(`Error creating external account for ${customerId}:`, error)
const sanitizedCustomerId = customerId.replace(/\r?\n/g, "");
console.error(`Error creating external account for customerId="${sanitizedCustomerId}":`, error)
if (error instanceof Error) {
return { error: error.message }
}
Copilot is powered by AI and may make mistakes. Always verify output.
clearInterval(pollInterval)
// track completed event from iframe and close the modal
useEffect(() => {
const handleMessage = (event: MessageEvent) => {

Check warning

Code scanning / CodeQL

Missing origin verification in `postMessage` handler Medium

Postmessage handler has no origin check.

Copilot Autofix

AI 5 months ago

The best way to fix this problem is to verify the event.origin property in the handleMessage function against a whitelist of expected origins. Let the developer specify the trusted domain(s) (e.g., only accept messages from the same origin as the iframe src, or a hardcoded domain string). Since the iframe's origin is configurable via the src prop, a good, maintainable fix is to extract the origin from the src whenever possible and compare event.origin against this extracted origin (for robustness and flexibility).

Specifically, in src/components/Global/IframeWrapper/index.tsx, within the handleMessage function in the useEffect at lines 94-108, add code to determine the trusted origin and then check if event.origin matches before processing sensitive logic. Additionally, ensure robust parsing of the src and fallback as needed. Optionally, add a console warning for unexpected origins.

No external library is required—URL is a globally available API in all modern browsers and Node.js.

Suggested changeset 1
src/components/Global/IframeWrapper/index.tsx

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/src/components/Global/IframeWrapper/index.tsx b/src/components/Global/IframeWrapper/index.tsx
--- a/src/components/Global/IframeWrapper/index.tsx
+++ b/src/components/Global/IframeWrapper/index.tsx
@@ -91,7 +91,21 @@
 
     // track completed event from iframe and close the modal
     useEffect(() => {
+        // Determine trusted origin from iframe src, fallback to empty string
+        let trustedOrigin = '';
+        try {
+            // src may be relative or absolute. Handle both cases.
+            const url = new URL(src, window.location.origin);
+            trustedOrigin = url.origin;
+        } catch (err) {
+            // If src is invalid, trustedOrigin remains empty.
+        }
         const handleMessage = (event: MessageEvent) => {
+            if (event.origin !== trustedOrigin) {
+                // Optionally: warn about rejected messages or just silently ignore
+                // console.warn(`Blocked postMessage from untrusted origin: ${event.origin}`);
+                return;
+            }
             const data = event.data
             if (data?.name === 'complete' && data?.metadata?.status === 'completed') {
                 onClose('completed')
@@ -105,7 +118,7 @@
 
         window.addEventListener('message', handleMessage)
         return () => window.removeEventListener('message', handleMessage)
-    }, [onClose])
+    }, [onClose, src])
 
     return (
         <Modal
EOF
@@ -91,7 +91,21 @@

// track completed event from iframe and close the modal
useEffect(() => {
// Determine trusted origin from iframe src, fallback to empty string
let trustedOrigin = '';
try {
// src may be relative or absolute. Handle both cases.
const url = new URL(src, window.location.origin);
trustedOrigin = url.origin;
} catch (err) {
// If src is invalid, trustedOrigin remains empty.
}
const handleMessage = (event: MessageEvent) => {
if (event.origin !== trustedOrigin) {
// Optionally: warn about rejected messages or just silently ignore
// console.warn(`Blocked postMessage from untrusted origin: ${event.origin}`);
return;
}
const data = event.data
if (data?.name === 'complete' && data?.metadata?.status === 'completed') {
onClose('completed')
@@ -105,7 +118,7 @@

window.addEventListener('message', handleMessage)
return () => window.removeEventListener('message', handleMessage)
}, [onClose])
}, [onClose, src])

return (
<Modal
Copilot is powered by AI and may make mistakes. Always verify output.
let fromAmount: string
let response: SquidRouteResponse

console.info('getRoute', { from, to }, amount)

Check warning

Code scanning / CodeQL

Log injection Medium

Log entry depends on a
user-provided value
.

Copilot Autofix

AI 5 months ago

To fix this log injection issue, sanitize all user-input values before logging them. The best approach is to replace any newline (\n) and carriage return (\r) characters in all string values in the amount object before including it in the log entry. Since amount can contain multiple properties, we should shallow-clone and sanitize each string property. For non-string or null/undefined properties, log them as-is.

This can be accomplished by constructing a new object (e.g. sanitizedAmount) where for each property in amount, if the property is a string, replace all \n and \r with empty strings. Then, log sanitizedAmount in place of the original user-supplied amount object.

All changes are limited to the code region around the logging call in src/services/swap.ts, and do not impact functionality elsewhere.


Suggested changeset 1
src/services/swap.ts

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/src/services/swap.ts b/src/services/swap.ts
--- a/src/services/swap.ts
+++ b/src/services/swap.ts
@@ -428,7 +428,13 @@
     let fromAmount: string
     let response: SquidRouteResponse
 
-    console.info('getRoute', { from, to }, amount)
+    // Sanitize user-controlled amount fields to prevent log injection
+    const sanitizedAmount = Object.fromEntries(
+        Object.entries(amount).map(([key, value]) =>
+            [key, typeof value === 'string' ? value.replace(/[\n\r]/g, '') : value]
+        )
+    );
+    console.info('getRoute', { from, to }, sanitizedAmount)
 
     if (amount.fromAmount) {
         fromAmount = amount.fromAmount.toString()
EOF
@@ -428,7 +428,13 @@
let fromAmount: string
let response: SquidRouteResponse

console.info('getRoute', { from, to }, amount)
// Sanitize user-controlled amount fields to prevent log injection
const sanitizedAmount = Object.fromEntries(
Object.entries(amount).map(([key, value]) =>
[key, typeof value === 'string' ? value.replace(/[\n\r]/g, '') : value]
)
);
console.info('getRoute', { from, to }, sanitizedAmount)

if (amount.fromAmount) {
fromAmount = amount.fromAmount.toString()
Copilot is powered by AI and may make mistakes. Always verify output.
break
}
} catch (error) {
console.error('Error parsing WebSocket message:', error, event.data)

Check warning

Code scanning / CodeQL

Log injection Medium

Log entry depends on a
user-provided value
.

Copilot Autofix

AI 5 months ago

To fix the log injection vulnerability, we should sanitize event.data before including it in log output. For plain text logs (as here), the most important step is removing any linebreak characters (carriage return, line feed) and clearly differentiating user input from log structure. The best solution is to convert event.data to string (if not already), then remove all \n and \r characters using String.prototype.replace or a utility function. Only the affected line needs to be changed for this fix, requiring no additional dependencies or methods. If event.data is not a string, convert it via String(event.data) or JSON.stringify (if object).

No imports or project-wide changes are needed; just sanitize the event data before logging on line 137 of src/services/websocket.ts.

Suggested changeset 1
src/services/websocket.ts

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/src/services/websocket.ts b/src/services/websocket.ts
--- a/src/services/websocket.ts
+++ b/src/services/websocket.ts
@@ -134,7 +134,8 @@
                     break
             }
         } catch (error) {
-            console.error('Error parsing WebSocket message:', error, event.data)
+            const sanitizedData = String(event.data).replace(/[\n\r]/g, "");
+            console.error('Error parsing WebSocket message:', error, sanitizedData)
         }
     }
 
EOF
@@ -134,7 +134,8 @@
break
}
} catch (error) {
console.error('Error parsing WebSocket message:', error, event.data)
const sanitizedData = String(event.data).replace(/[\n\r]/g, "");
console.error('Error parsing WebSocket message:', error, sanitizedData)
}
}

Copilot is powered by AI and may make mistakes. Always verify output.
Comment on lines +58 to +61
const response = await fetch(url, {
...options,
signal: controller.signal,
})

Check failure

Code scanning / CodeQL

Server-side request forgery Critical

The
URL
of this request depends on a
user-provided value
.
The
URL
of this request depends on a
user-provided value
.
The
URL
of this request depends on a
user-provided value
.
The
URL
of this request depends on a
user-provided value
.
The
URL
of this request depends on a
user-provided value
.
The
URL
of this request depends on a user-provided value.
The
URL
of this request depends on a user-provided value.
The
URL
of this request depends on a user-provided value.
The
URL
of this request depends on a user-provided value.

Copilot Autofix

AI 5 months ago

To address SSRF risk, ensure that any user-supplied value interpolated into a URL path—especially for outgoing server-to-server requests—cannot contain malicious data. Specifically:

  • Restrict customerId to allow only known-safe patterns (for example: a valid UUID, or strict numeric ID).
  • Validate the value before constructing the URL in createBridgeExternalAccountForGuest.
  • If the ID is expected to match a certain format, use a regular expression to enforce this (for example, a UUID).
  • If the value does not conform, throw an error or return early with an error message.

The best way to implement this with the minimum change and risk is to add this validation at the point where the untrusted data enters, i.e., immediately upon function entry for createBridgeExternalAccountForGuest. This should occur within src/app/actions/external-accounts.ts. No changes are required to fetchWithSentry in src/utils/sentry.utils.ts since endpoint composition should be secure before this utility is called.

If customerId is expected to be a UUID, verify using a regex. If it's meant to be an alphanumeric string, restrict accordingly.


Suggested changeset 1
src/app/actions/external-accounts.ts
Outside changed files

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/src/app/actions/external-accounts.ts b/src/app/actions/external-accounts.ts
--- a/src/app/actions/external-accounts.ts
+++ b/src/app/actions/external-accounts.ts
@@ -11,6 +11,11 @@
     customerId: string,
     accountDetails: AddBankAccountPayload
 ): Promise<IBridgeAccount | { error: string; source?: string }> {
+    // Validate that customerId is a UUID (edit the regex below if your customerId is another format)
+    const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
+    if (!uuidRegex.test(customerId)) {
+        return { error: "Invalid customerId format." };
+    }
     try {
         const response = await fetchWithSentry(`${API_URL}/bridge/customers/${customerId}/external-accounts`, {
             method: 'POST',
EOF
@@ -11,6 +11,11 @@
customerId: string,
accountDetails: AddBankAccountPayload
): Promise<IBridgeAccount | { error: string; source?: string }> {
// Validate that customerId is a UUID (edit the regex below if your customerId is another format)
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
if (!uuidRegex.test(customerId)) {
return { error: "Invalid customerId format." };
}
try {
const response = await fetchWithSentry(`${API_URL}/bridge/customers/${customerId}/external-accounts`, {
method: 'POST',
Copilot is powered by AI and may make mistakes. Always verify output.
@Hugo0 Hugo0 changed the base branch from main to peanut-wallet-dev November 3, 2025 05:54
@notion-workspace
Copy link

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/components/Global/DirectSendQR/index.tsx (1)

358-375: Use window navigation for external redirect URLs.

When a redirect QR is already claimed, the API returns an invite link like https://peanut.me/invite?.... Passing that absolute URL to router.push will throw (Invariant: attempted to navigate to a URL outside the app), so scanners hit an error instead of the invite. Detect absolute URLs and fall back to window.location before using the router for internal paths.

-                router.push(redirectUrl)
+                if (/^https?:\/\//.test(redirectUrl)) {
+                    window.location.href = redirectUrl
+                } else {
+                    router.push(redirectUrl)
+                }
                 setIsQRScannerOpen(false)
🧹 Nitpick comments (1)
src/hooks/useHoldToClaim.ts (1)

56-56: Extract magic number to a constant.

The 500 ms preview duration is hardcoded. Consider extracting it to a named constant at the file or module level for better maintainability.

+const PREVIEW_DURATION_MS = 500
+
 export function useHoldToClaim({
     onComplete,
     holdDuration = PERK_HOLD_DURATION_MS,
     disabled = false,
 }: UseHoldToClaimOptions): UseHoldToClaimReturn {

Then use it in cancelHold:

 const cancelHold = useCallback(() => {
-    const PREVIEW_DURATION_MS = 500
-
     // Calculate how long the user held
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between feae9ba and d107403.

📒 Files selected for processing (9)
  • src/app/(mobile-ui)/qr/[code]/page.tsx (1 hunks)
  • src/app/(mobile-ui)/qr/[code]/success/page.tsx (1 hunks)
  • src/app/[...recipient]/page.tsx (2 hunks)
  • src/components/Global/DirectSendQR/index.tsx (1 hunks)
  • src/components/Global/DirectSendQR/utils.ts (1 hunks)
  • src/components/Invites/InvitesPage.tsx (2 hunks)
  • src/hooks/useHoldToClaim.ts (1 hunks)
  • src/hooks/useRedirectQrStatus.ts (1 hunks)
  • src/middleware.ts (1 hunks)
🧰 Additional context used
🧠 Learnings (25)
📓 Common learnings
Learnt from: jjramirezn
Repo: peanutprotocol/peanut-ui PR: 1190
File: src/app/(mobile-ui)/qr-pay/page.tsx:156-176
Timestamp: 2025-09-08T03:13:09.111Z
Learning: In the peanut-ui mobile app, the `/qr-pay` route is only accessed through the DirectSendQR component which always includes the qrCode parameter in the URL when redirecting users to the QR pay page.
Learnt from: jjramirezn
Repo: peanutprotocol/peanut-ui PR: 1190
File: src/app/(mobile-ui)/qr-pay/page.tsx:156-176
Timestamp: 2025-09-08T03:13:09.111Z
Learning: In the peanut-ui mobile app, the `/qr-pay` route is only accessed through the DirectSendQR component which always includes the qrCode parameter in the URL when redirecting users to the QR pay page after scanning MERCADO_PAGO or PIX QR codes.
Learnt from: Hugo0
Repo: peanutprotocol/peanut-ui PR: 942
File: src/components/AddMoney/consts/index.ts:2151-2162
Timestamp: 2025-06-30T10:44:08.048Z
Learning: Hugo0 often agrees with refactoring suggestions but defers implementation due to time constraints, preferring to track improvements as follow-up issues when they're part of larger architectural changes.
📚 Learning: 2024-10-23T09:38:27.670Z
Learnt from: jjramirezn
Repo: peanutprotocol/peanut-ui PR: 469
File: src/app/request/pay/page.tsx:32-64
Timestamp: 2024-10-23T09:38:27.670Z
Learning: In `src/app/request/pay/page.tsx`, if `linkRes` is not OK in the `generateMetadata` function, the desired behavior is to use the standard title and preview image without throwing an error.

Applied to files:

  • src/app/[...recipient]/page.tsx
📚 Learning: 2025-05-02T19:14:25.010Z
Learnt from: kushagrasarathe
Repo: peanutprotocol/peanut-ui PR: 828
File: src/app/(mobile-ui)/request/[...username]/page.tsx:17-17
Timestamp: 2025-05-02T19:14:25.010Z
Learning: In Next.js App Router, catch-all routes (like `/request/[...username]`) will only match URLs with at least one parameter segment, ensuring that the page component only renders when there is at least one value in the params array.

Applied to files:

  • src/app/[...recipient]/page.tsx
📚 Learning: 2024-10-22T18:11:36.864Z
Learnt from: jjramirezn
Repo: peanutprotocol/peanut-ui PR: 469
File: src/app/request/pay/page.tsx:32-49
Timestamp: 2024-10-22T18:11:36.864Z
Learning: In `src/app/request/pay/page.tsx`, the `id` parameter is accessed via `searchParams.id` in the `generateMetadata` function.

Applied to files:

  • src/app/[...recipient]/page.tsx
📚 Learning: 2024-10-08T20:13:42.967Z
Learnt from: Hugo0
Repo: peanutprotocol/peanut-ui PR: 422
File: src/components/Request/Pay/Pay.tsx:113-123
Timestamp: 2024-10-08T20:13:42.967Z
Learning: In the `PayRequestLink` component (`src/components/Request/Pay/Pay.tsx`), when resolving ENS names, handle errors by displaying an appropriate error message to the user if the ENS cannot be resolved.

Applied to files:

  • src/app/[...recipient]/page.tsx
📚 Learning: 2025-05-13T10:05:24.057Z
Learnt from: kushagrasarathe
Repo: peanutprotocol/peanut-ui PR: 845
File: src/components/Request/link/views/Create.request.link.view.tsx:81-81
Timestamp: 2025-05-13T10:05:24.057Z
Learning: In the peanut-ui project, pages that handle request flows (like Create.request.link.view.tsx) are only accessible to logged-in users who will always have a username, making null checks for user?.user.username unnecessary in these contexts.

Applied to files:

  • src/app/[...recipient]/page.tsx
  • src/components/Invites/InvitesPage.tsx
📚 Learning: 2025-09-05T07:31:11.396Z
Learnt from: Zishan-7
Repo: peanutprotocol/peanut-ui PR: 1185
File: src/components/Claim/useClaimLink.tsx:14-0
Timestamp: 2025-09-05T07:31:11.396Z
Learning: In the peanut-ui codebase, `window.history.replaceState` is preferred over `router.replace` when immediate/synchronous URL parameter updates are required, as `router.replace` is asynchronous and doesn't guarantee instant URL changes that subsequent code can rely on. This pattern is used consistently across usePaymentInitiator.ts, Confirm.payment.view.tsx, and useClaimLink.tsx.

Applied to files:

  • src/app/[...recipient]/page.tsx
  • src/components/Global/DirectSendQR/index.tsx
  • src/app/(mobile-ui)/qr/[code]/success/page.tsx
  • src/app/(mobile-ui)/qr/[code]/page.tsx
📚 Learning: 2024-10-23T09:38:04.446Z
Learnt from: jjramirezn
Repo: peanutprotocol/peanut-ui PR: 469
File: src/app/request/pay/page.tsx:32-64
Timestamp: 2024-10-23T09:38:04.446Z
Learning: Within `src/app/request/pay/page.tsx`, extracting the `getBaseUrl` function does not add significant readability, and the host URL construction code is expected to change soon.

Applied to files:

  • src/app/[...recipient]/page.tsx
📚 Learning: 2025-05-23T19:26:58.220Z
Learnt from: jjramirezn
Repo: peanutprotocol/peanut-ui PR: 873
File: src/components/Withdraw/views/Initial.withdraw.view.tsx:95-95
Timestamp: 2025-05-23T19:26:58.220Z
Learning: The GeneralRecipientInput component supports username validation and resolution through the validateAndResolveRecipient function in src/lib/validation/recipient.ts. The function automatically detects usernames (inputs that don't contain '.' for ENS and don't start with '0x' for addresses), validates them via API HEAD request, fetches user data, and resolves them to Ethereum addresses from the user's PEANUT_WALLET account.

Applied to files:

  • src/app/[...recipient]/page.tsx
  • src/components/Global/DirectSendQR/utils.ts
📚 Learning: 2025-09-08T03:13:09.111Z
Learnt from: jjramirezn
Repo: peanutprotocol/peanut-ui PR: 1190
File: src/app/(mobile-ui)/qr-pay/page.tsx:156-176
Timestamp: 2025-09-08T03:13:09.111Z
Learning: In the peanut-ui mobile app, the `/qr-pay` route is only accessed through the DirectSendQR component which always includes the qrCode parameter in the URL when redirecting users to the QR pay page.

Applied to files:

  • src/app/[...recipient]/page.tsx
  • src/components/Global/DirectSendQR/index.tsx
  • src/hooks/useRedirectQrStatus.ts
  • src/components/Global/DirectSendQR/utils.ts
  • src/app/(mobile-ui)/qr/[code]/success/page.tsx
  • src/app/(mobile-ui)/qr/[code]/page.tsx
📚 Learning: 2025-09-08T03:13:09.111Z
Learnt from: jjramirezn
Repo: peanutprotocol/peanut-ui PR: 1190
File: src/app/(mobile-ui)/qr-pay/page.tsx:156-176
Timestamp: 2025-09-08T03:13:09.111Z
Learning: In the peanut-ui mobile app, the `/qr-pay` route is only accessed through the DirectSendQR component which always includes the qrCode parameter in the URL when redirecting users to the QR pay page after scanning MERCADO_PAGO or PIX QR codes.

Applied to files:

  • src/components/Global/DirectSendQR/index.tsx
  • src/app/(mobile-ui)/qr/[code]/success/page.tsx
  • src/app/(mobile-ui)/qr/[code]/page.tsx
📚 Learning: 2025-07-24T13:26:10.290Z
Learnt from: Hugo0
Repo: peanutprotocol/peanut-ui PR: 1014
File: src/components/Claim/Link/Initial.view.tsx:413-413
Timestamp: 2025-07-24T13:26:10.290Z
Learning: In the peanut-ui repository, the change from `${SQUID_API_URL}/route` to `${SQUID_API_URL}/v2/route` in src/components/Claim/Link/Initial.view.tsx was a typo fix, not an API migration, as the codebase was already using Squid API v2.

Applied to files:

  • src/components/Global/DirectSendQR/index.tsx
  • src/components/Global/DirectSendQR/utils.ts
  • src/app/(mobile-ui)/qr/[code]/page.tsx
  • src/middleware.ts
📚 Learning: 2025-09-12T09:06:22.741Z
Learnt from: Zishan-7
Repo: peanutprotocol/peanut-ui PR: 1207
File: src/components/Global/PostSignupActionManager/post-signup-action.consts.ts:0-0
Timestamp: 2025-09-12T09:06:22.741Z
Learning: In the peanut-ui codebase, redirect URLs are always stored as relative paths, never as absolute paths. This means regex patterns can safely anchor to `^\/` when matching stored redirect URLs.

Applied to files:

  • src/components/Global/DirectSendQR/index.tsx
📚 Learning: 2025-08-07T12:53:50.946Z
Learnt from: Zishan-7
Repo: peanutprotocol/peanut-ui PR: 1072
File: src/app/(setup)/setup/page.tsx:173-175
Timestamp: 2025-08-07T12:53:50.946Z
Learning: In the peanut-ui setup flow at `src/app/(setup)/setup/page.tsx`, when handling unsupported scenarios, both device not supported and browser not supported cases should show the same "Unsupported browser" message using the `UnsupportedBrowserModal` component, rather than having distinct messaging for each scenario.

Applied to files:

  • src/components/Global/DirectSendQR/index.tsx
📚 Learning: 2025-10-02T15:23:01.513Z
Learnt from: Zishan-7
Repo: peanutprotocol/peanut-ui PR: 1266
File: src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx:46-57
Timestamp: 2025-10-02T15:23:01.513Z
Learning: In the withdraw flow at src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx, the points calculation query intentionally uses crypto.randomUUID() in the queryKey dependency array to bypass React Query caching, ensuring fresh points estimates on every render. This is the intended behavior.

Applied to files:

  • src/hooks/useRedirectQrStatus.ts
📚 Learning: 2024-10-29T16:06:38.812Z
Learnt from: jjramirezn
Repo: peanutprotocol/peanut-ui PR: 495
File: src/components/Create/useCreateLink.tsx:647-657
Timestamp: 2024-10-29T16:06:38.812Z
Learning: In the React code for `useCreateLink` in `src/components/Create/useCreateLink.tsx`, the `switchNetwork` function used within `useCallback` hooks is stable and does not need to be included in the dependency arrays.

Applied to files:

  • src/hooks/useRedirectQrStatus.ts
  • src/components/Invites/InvitesPage.tsx
📚 Learning: 2025-08-14T14:42:54.411Z
Learnt from: Zishan-7
Repo: peanutprotocol/peanut-ui PR: 1094
File: src/utils/withdraw.utils.ts:181-191
Timestamp: 2025-08-14T14:42:54.411Z
Learning: The countryCodeMap in src/components/AddMoney/consts/index.ts uses uppercase 3-letter country codes as keys (like 'AUT', 'BEL', 'CZE') that map to 2-letter country codes, requiring input normalization to uppercase for proper lookups.

Applied to files:

  • src/components/Global/DirectSendQR/utils.ts
📚 Learning: 2025-08-12T17:44:04.268Z
Learnt from: Zishan-7
Repo: peanutprotocol/peanut-ui PR: 1089
File: src/components/LandingPage/dropLink.tsx:35-42
Timestamp: 2025-08-12T17:44:04.268Z
Learning: In the Peanut UI project, opening the `/setup` route in a new tab from landing page CTAs is intentional design behavior to keep users on the marketing page while they start the setup process.

Applied to files:

  • src/app/(mobile-ui)/qr/[code]/success/page.tsx
📚 Learning: 2025-09-29T18:34:33.596Z
Learnt from: Zishan-7
Repo: peanutprotocol/peanut-ui PR: 1251
File: src/components/Invites/JoinWaitlistPage.tsx:41-55
Timestamp: 2025-09-29T18:34:33.596Z
Learning: In the JoinWaitlistPage component, after successfully accepting an invite via invitesApi.acceptInvite(), calling fetchUser() is sufficient to update the user state and automatically display the app. No manual navigation to /home or other pages is required since the user is already on the home page and the app will be displayed once user.hasAppAccess is updated.

Applied to files:

  • src/app/(mobile-ui)/qr/[code]/success/page.tsx
  • src/components/Invites/InvitesPage.tsx
📚 Learning: 2025-10-08T17:13:13.155Z
Learnt from: Zishan-7
Repo: peanutprotocol/peanut-ui PR: 1299
File: src/app/(mobile-ui)/points/page.tsx:41-51
Timestamp: 2025-10-08T17:13:13.155Z
Learning: In `src/app/(mobile-ui)/points/page.tsx`, the icon name "invite-heart" is intentionally used (not "inviter-heart") when displaying who invited the current user, as this is a deliberate design choice despite semantic differences with UserHeader usage.

Applied to files:

  • src/components/Invites/InvitesPage.tsx
📚 Learning: 2025-09-18T09:30:42.901Z
Learnt from: Zishan-7
Repo: peanutprotocol/peanut-ui PR: 1230
File: src/app/(mobile-ui)/withdraw/page.tsx:92-97
Timestamp: 2025-09-18T09:30:42.901Z
Learning: In src/app/(mobile-ui)/withdraw/page.tsx, the useEffect that calls setShowAllWithdrawMethods(true) when amountFromContext exists is intentionally designed to run only on component mount (empty dependency array), not when amountFromContext changes. This is the correct behavior for the withdraw flow where showing all methods should only happen on initial load when an amount is already present.

Applied to files:

  • src/components/Invites/InvitesPage.tsx
📚 Learning: 2025-07-05T16:58:25.340Z
Learnt from: Hugo0
Repo: peanutprotocol/peanut-ui PR: 0
File: :0-0
Timestamp: 2025-07-05T16:58:25.340Z
Learning: Hugo0 successfully refactored sessionStorage usage to React Context in the onramp flow, demonstrating preference for centralized state management over browser storage for component-shared state in React applications.

Applied to files:

  • src/components/Invites/InvitesPage.tsx
📚 Learning: 2024-12-02T17:19:18.532Z
Learnt from: jjramirezn
Repo: peanutprotocol/peanut-ui PR: 551
File: src/components/Request/Create/Views/Initial.view.tsx:151-156
Timestamp: 2024-12-02T17:19:18.532Z
Learning: In the `InitialView` component at `src/components/Request/Create/Views/Initial.view.tsx`, when setting the default chain and token in the `useEffect` triggered by `isPeanutWallet`, it's acceptable to omit the setters from the dependency array and not include additional error handling for invalid defaults.

Applied to files:

  • src/components/Invites/InvitesPage.tsx
📚 Learning: 2025-09-11T17:46:12.507Z
Learnt from: Hugo0
Repo: peanutprotocol/peanut-ui PR: 1200
File: src/app/(mobile-ui)/recover-funds/page.tsx:9-9
Timestamp: 2025-09-11T17:46:12.507Z
Learning: Functions in Next.js that are not marked with "use server" and contain secrets are unsafe to import in client components, as they get bundled into the client JavaScript and can leak environment variables to the browser.

Applied to files:

  • src/components/Invites/InvitesPage.tsx
📚 Learning: 2025-05-22T15:38:48.586Z
Learnt from: kushagrasarathe
Repo: peanutprotocol/peanut-ui PR: 869
File: src/app/(mobile-ui)/withdraw/page.tsx:82-88
Timestamp: 2025-05-22T15:38:48.586Z
Learning: The country-specific withdrawal route exists at src/app/(mobile-ui)/withdraw/[...country]/page.tsx and renders the AddWithdrawCountriesList component with flow="withdraw".

Applied to files:

  • src/app/(mobile-ui)/qr/[code]/page.tsx
🔇 Additional comments (3)
src/hooks/useHoldToClaim.ts (3)

1-28: LGTM! Well-structured type definitions.

The type definitions are clear and comprehensive. The inclusion of keyboard handlers and accessibility-focused styling props in UseHoldToClaimReturn demonstrates good attention to accessibility.


34-53: LGTM! Proper cleanup on unmount.

The cleanup effect correctly clears all timers and resets references on unmount, preventing memory leaks.


165-200: LGTM! Well-implemented button props with accessibility support.

The button props provide comprehensive event handling for both pointer and keyboard interactions. The prevention of context menus, text selection, and tap highlights ensures a smooth touch experience. The keyboard support (Enter and Space keys) makes the interaction accessible.

Comment on lines +45 to +55
if (isLoading || !redirectQrData) {
return (
<div className="flex min-h-[inherit] flex-col gap-8">
<NavHeader title="Success" />
<div className="flex h-full items-center justify-center">
<PeanutLoading />
</div>
</div>
)
}

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Handle redirect status errors on success page.

If useRedirectQrStatus rejects (network error, 404, etc.), redirectQrData stays undefined while isLoading is already false, so this branch renders the loading screen forever and the user never sees a failure message. Please surface an explicit error state before the loader to let users recover.

-import { useRedirectQrStatus } from '@/hooks/useRedirectQrStatus'
+import { useRedirectQrStatus } from '@/hooks/useRedirectQrStatus'
+import ErrorAlert from '@/components/Global/ErrorAlert'-    const { data: redirectQrData, isLoading } = useRedirectQrStatus(code)
+    const { data: redirectQrData, isLoading, error } = useRedirectQrStatus(code)-    if (isLoading || !redirectQrData) {
+    if (error) {
+        return (
+            <div className="flex min-h-[inherit] flex-col gap-8">
+                <NavHeader title="Success" />
+                <div className="flex h-full items-center justify-center px-6">
+                    <ErrorAlert description="We couldn't load your QR details. Please try again." />
+                </div>
+            </div>
+        )
+    }
+
+    if (isLoading || !redirectQrData) {
         return (
             <div className="flex min-h-[inherit] flex-col gap-8">
                 <NavHeader title="Success" />
                 <div className="flex h-full items-center justify-center">
                     <PeanutLoading />
                 </div>
             </div>
         )
     }

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In src/app/(mobile-ui)/qr/[code]/success/page.tsx around lines 45 to 55, the
component returns the loading UI when redirectQrData is undefined even if the
useRedirectQrStatus hook has errored; update the render logic to explicitly
check the hook's error state (e.g., isError or error) and, before showing the
loader, render an error state/alert with a user-facing message and recovery
options (retry button or link back) plus the NavHeader; keep the loader path
only for isLoading === true and ensure the error rendering covers network/404
cases so the user is not stuck on the spinner.

Comment on lines +100 to +163
const startHold = useCallback(() => {
if (disabled) return

setHoldProgress(0)
setIsShaking(true)

const startTime = Date.now()
holdStartTimeRef.current = startTime
let lastIntensity: ShakeIntensity = 'weak'

// Update progress and shake intensity
const interval = setInterval(() => {
const elapsed = Date.now() - startTime
const progress = Math.min((elapsed / holdDuration) * 100, 100)
setHoldProgress(progress)

// Progressive shake intensity with haptic feedback
let newIntensity: ShakeIntensity = 'weak'
if (progress < 25) {
newIntensity = 'weak'
} else if (progress < 50) {
newIntensity = 'medium'
} else if (progress < 75) {
newIntensity = 'strong'
} else {
newIntensity = 'intense'
}

// Trigger haptic feedback when intensity changes
if (newIntensity !== lastIntensity && 'vibrate' in navigator) {
// Progressive vibration patterns that match shake intensity
switch (newIntensity) {
case 'weak':
navigator.vibrate(50) // Short but noticeable pulse
break
case 'medium':
navigator.vibrate([100, 40, 100]) // Medium pulse pattern
break
case 'strong':
navigator.vibrate([150, 40, 150, 40, 150]) // Strong pulse pattern
break
case 'intense':
navigator.vibrate([200, 40, 200, 40, 200, 40, 200]) // INTENSE pulse pattern
break
}
lastIntensity = newIntensity
}

setShakeIntensity(newIntensity)

if (progress >= 100) {
clearInterval(interval)
}
}, 50)

progressIntervalRef.current = interval

// Complete after hold duration
const timer = setTimeout(() => {
onComplete()
}, holdDuration)

holdTimerRef.current = timer
}, [onComplete, holdDuration, disabled])
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Critical: Race condition with preview reset timer.

When startHold is called, it doesn't clear existing timers before setting new ones. This creates a race condition:

  1. User taps briefly → startHold sets completion timer
  2. User releases quickly → cancelHold sets preview reset timer in holdTimerRef (line 84)
  3. User taps again before preview completes → startHold overwrites holdTimerRef without clearing the preview timer
  4. The orphaned preview reset timer fires → resets state while user is actively holding the button

Apply this diff to clear existing timers before starting a new hold:

 const startHold = useCallback(() => {
     if (disabled) return
+
+    // Clear any existing timers to prevent race conditions
+    if (holdTimerRef.current) clearTimeout(holdTimerRef.current)
+    if (progressIntervalRef.current) clearInterval(progressIntervalRef.current)
+    holdTimerRef.current = null
+    progressIntervalRef.current = null

     setHoldProgress(0)
     setIsShaking(true)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const startHold = useCallback(() => {
if (disabled) return
setHoldProgress(0)
setIsShaking(true)
const startTime = Date.now()
holdStartTimeRef.current = startTime
let lastIntensity: ShakeIntensity = 'weak'
// Update progress and shake intensity
const interval = setInterval(() => {
const elapsed = Date.now() - startTime
const progress = Math.min((elapsed / holdDuration) * 100, 100)
setHoldProgress(progress)
// Progressive shake intensity with haptic feedback
let newIntensity: ShakeIntensity = 'weak'
if (progress < 25) {
newIntensity = 'weak'
} else if (progress < 50) {
newIntensity = 'medium'
} else if (progress < 75) {
newIntensity = 'strong'
} else {
newIntensity = 'intense'
}
// Trigger haptic feedback when intensity changes
if (newIntensity !== lastIntensity && 'vibrate' in navigator) {
// Progressive vibration patterns that match shake intensity
switch (newIntensity) {
case 'weak':
navigator.vibrate(50) // Short but noticeable pulse
break
case 'medium':
navigator.vibrate([100, 40, 100]) // Medium pulse pattern
break
case 'strong':
navigator.vibrate([150, 40, 150, 40, 150]) // Strong pulse pattern
break
case 'intense':
navigator.vibrate([200, 40, 200, 40, 200, 40, 200]) // INTENSE pulse pattern
break
}
lastIntensity = newIntensity
}
setShakeIntensity(newIntensity)
if (progress >= 100) {
clearInterval(interval)
}
}, 50)
progressIntervalRef.current = interval
// Complete after hold duration
const timer = setTimeout(() => {
onComplete()
}, holdDuration)
holdTimerRef.current = timer
}, [onComplete, holdDuration, disabled])
const startHold = useCallback(() => {
if (disabled) return
// Clear any existing timers to prevent race conditions
if (holdTimerRef.current) clearTimeout(holdTimerRef.current)
if (progressIntervalRef.current) clearInterval(progressIntervalRef.current)
holdTimerRef.current = null
progressIntervalRef.current = null
setHoldProgress(0)
setIsShaking(true)
const startTime = Date.now()
holdStartTimeRef.current = startTime
let lastIntensity: ShakeIntensity = 'weak'
// Update progress and shake intensity
const interval = setInterval(() => {
const elapsed = Date.now() - startTime
const progress = Math.min((elapsed / holdDuration) * 100, 100)
setHoldProgress(progress)
// Progressive shake intensity with haptic feedback
let newIntensity: ShakeIntensity = 'weak'
if (progress < 25) {
newIntensity = 'weak'
} else if (progress < 50) {
newIntensity = 'medium'
} else if (progress < 75) {
newIntensity = 'strong'
} else {
newIntensity = 'intense'
}
// Trigger haptic feedback when intensity changes
if (newIntensity !== lastIntensity && 'vibrate' in navigator) {
// Progressive vibration patterns that match shake intensity
switch (newIntensity) {
case 'weak':
navigator.vibrate(50) // Short but noticeable pulse
break
case 'medium':
navigator.vibrate([100, 40, 100]) // Medium pulse pattern
break
case 'strong':
navigator.vibrate([150, 40, 150, 40, 150]) // Strong pulse pattern
break
case 'intense':
navigator.vibrate([200, 40, 200, 40, 200, 40, 200]) // INTENSE pulse pattern
break
}
lastIntensity = newIntensity
}
setShakeIntensity(newIntensity)
if (progress >= 100) {
clearInterval(interval)
}
}, 50)
progressIntervalRef.current = interval
// Complete after hold duration
const timer = setTimeout(() => {
onComplete()
}, holdDuration)
holdTimerRef.current = timer
}, [onComplete, holdDuration, disabled])

@notion-workspace
Copy link

Hugo0 added 4 commits November 3, 2025 13:49
- Remove console.log debug statements from QR claim page
- Remove console.log debug statements from InvitesPage
- Restore RESERVED_ROUTES and route guards in catch-all page
- Restore QR scanner integration for redirect QR codes
- Restore middleware QR route handling
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/components/Global/ShareButton/index.tsx (1)

44-67: Refactor to use shared clipboard utility and fix execCommand bug.

This function duplicates logic from src/utils/general.utils.ts (lines 392-418) and introduces a subtle bug: Line 59 doesn't check the return value of document.execCommand('copy'), so the function always returns true at line 61 even if the copy fails. This causes the component to show "Link copied" toast when the clipboard operation actually failed.

Consider updating the shared copyTextToClipboardWithFallback utility to return a boolean indicating success, then import and use it here:

-    const copyTextToClipboardWithFallback = async (text: string) => {
-        try {
-            if (navigator.clipboard && window.isSecureContext) {
-                await navigator.clipboard.writeText(text)
-                return true
-            } else {
-                // Fallback for older browsers
-                const textArea = document.createElement('textarea')
-                textArea.value = text
-                textArea.style.position = 'fixed'
-                textArea.style.left = '-999999px'
-                textArea.style.top = '-999999px'
-                document.body.appendChild(textArea)
-                textArea.focus()
-                textArea.select()
-                document.execCommand('copy')
-                document.body.removeChild(textArea)
-                return true
-            }
-        } catch (err) {
-            console.error('Failed to copy: ', err)
-            return false
-        }
-    }

In src/utils/general.utils.ts, update the function to return success status:

export async function copyTextToClipboardWithFallback(text: string): Promise<boolean> {
    if (navigator.clipboard && window.isSecureContext) {
        try {
            await navigator.clipboard.writeText(text)
            return true
        } catch (err) {
            Sentry.captureException(err)
            console.error('Clipboard API failed, trying fallback method. Error:', err)
        }
    }

    try {
        const textarea = document.createElement('textarea')
        textarea.value = text
        textarea.setAttribute('readonly', '')
        textarea.style.position = 'absolute'
        textarea.style.left = '-9999px'
        document.body.appendChild(textarea)
        textarea.select()
        const successful = document.execCommand('copy')
        document.body.removeChild(textarea)
        return successful
    } catch (err) {
        Sentry.captureException(err)
        console.error('Fallback method failed. Error:', err)
        return false
    }
}

Then import and use it in this component:

import { copyTextToClipboardWithFallback } from '@/utils/general.utils'
♻️ Duplicate comments (1)
src/app/(mobile-ui)/qr/[code]/success/page.tsx (1)

36-45: Handle fetch errors before showing the spinner.

If useRedirectQrStatus rejects (network failure, QR already claimed, etc.), redirectQrData stays undefined while isLoading becomes false, so this branch renders the loading screen indefinitely and the user never sees what went wrong. Surface an explicit error path (e.g., check error/isError from the query) before the loader so people can retry or navigate away instead of being stuck on a spinner.

🧹 Nitpick comments (2)
src/components/Global/ActionModal/index.tsx (1)

109-109: Border removal works correctly; consider simplification.

The addition of !border-0 ensures border removal with !important precedence. Since the base Modal component now includes border-0 in its default classes (line 80 of Modal/index.tsx), the ! modifier provides extra override protection but may be redundant. The current implementation functions correctly via twMerge conflict resolution.

If the !important override isn't needed for specific conflicts, you could simplify by removing !border-0 here and relying on Modal's border-0:

-                'sm:m-auto sm:self-center self-center m-4 bg-white rounded-none !border-0 z-50',
+                'sm:m-auto sm:self-center self-center m-4 bg-white rounded-none z-50',
src/constants/routes.ts (1)

94-97: Remove unsafe type cast and handle undefined explicitly.

The as any cast bypasses TypeScript's type safety. Since firstSegment can be undefined when the path has no segments, handle this case explicitly.

Apply this diff:

 export function isReservedRoute(path: string): boolean {
     const firstSegment = path.split('/')[1]?.toLowerCase()
-    return RESERVED_ROUTES.includes(firstSegment as any)
+    return firstSegment ? RESERVED_ROUTES.includes(firstSegment) : false
 }
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between d107403 and 1d1e570.

📒 Files selected for processing (18)
  • docs/ROUTES_CONFIG.md (1 hunks)
  • knip.json (1 hunks)
  • public/game/peanut-game.html (2 hunks)
  • public/onesignal/OneSignalSDKWorker.js (1 hunks)
  • src/app/(mobile-ui)/layout.tsx (2 hunks)
  • src/app/(mobile-ui)/qr/[code]/page.tsx (1 hunks)
  • src/app/(mobile-ui)/qr/[code]/success/page.tsx (1 hunks)
  • src/app/[...recipient]/page.tsx (2 hunks)
  • src/app/layout.tsx (1 hunks)
  • src/components/Global/ActionModal/index.tsx (1 hunks)
  • src/components/Global/Modal/index.tsx (1 hunks)
  • src/components/Global/ShareButton/index.tsx (2 hunks)
  • src/components/Invites/InvitesPage.tsx (2 hunks)
  • src/components/Profile/components/PublicProfile.tsx (1 hunks)
  • src/constants/index.ts (1 hunks)
  • src/constants/routes.ts (1 hunks)
  • src/hooks/query/user.ts (2 hunks)
  • src/middleware.ts (2 hunks)
✅ Files skipped from review due to trivial changes (2)
  • src/app/layout.tsx
  • docs/ROUTES_CONFIG.md
🚧 Files skipped from review as they are similar to previous changes (3)
  • src/middleware.ts
  • src/app/(mobile-ui)/qr/[code]/page.tsx
  • src/components/Invites/InvitesPage.tsx
🧰 Additional context used
🧠 Learnings (19)
📓 Common learnings
Learnt from: jjramirezn
Repo: peanutprotocol/peanut-ui PR: 1190
File: src/app/(mobile-ui)/qr-pay/page.tsx:156-176
Timestamp: 2025-09-08T03:13:09.111Z
Learning: In the peanut-ui mobile app, the `/qr-pay` route is only accessed through the DirectSendQR component which always includes the qrCode parameter in the URL when redirecting users to the QR pay page.
Learnt from: jjramirezn
Repo: peanutprotocol/peanut-ui PR: 1190
File: src/app/(mobile-ui)/qr-pay/page.tsx:156-176
Timestamp: 2025-09-08T03:13:09.111Z
Learning: In the peanut-ui mobile app, the `/qr-pay` route is only accessed through the DirectSendQR component which always includes the qrCode parameter in the URL when redirecting users to the QR pay page after scanning MERCADO_PAGO or PIX QR codes.
Learnt from: Hugo0
Repo: peanutprotocol/peanut-ui PR: 942
File: src/components/AddMoney/consts/index.ts:2151-2162
Timestamp: 2025-06-30T10:44:08.048Z
Learning: Hugo0 often agrees with refactoring suggestions but defers implementation due to time constraints, preferring to track improvements as follow-up issues when they're part of larger architectural changes.
📚 Learning: 2025-07-24T13:26:10.290Z
Learnt from: Hugo0
Repo: peanutprotocol/peanut-ui PR: 1014
File: src/components/Claim/Link/Initial.view.tsx:413-413
Timestamp: 2025-07-24T13:26:10.290Z
Learning: In the peanut-ui repository, the change from `${SQUID_API_URL}/route` to `${SQUID_API_URL}/v2/route` in src/components/Claim/Link/Initial.view.tsx was a typo fix, not an API migration, as the codebase was already using Squid API v2.

Applied to files:

  • src/app/(mobile-ui)/layout.tsx
  • src/app/[...recipient]/page.tsx
📚 Learning: 2024-10-23T09:38:04.446Z
Learnt from: jjramirezn
Repo: peanutprotocol/peanut-ui PR: 469
File: src/app/request/pay/page.tsx:32-64
Timestamp: 2024-10-23T09:38:04.446Z
Learning: Within `src/app/request/pay/page.tsx`, extracting the `getBaseUrl` function does not add significant readability, and the host URL construction code is expected to change soon.

Applied to files:

  • src/app/(mobile-ui)/layout.tsx
  • src/app/[...recipient]/page.tsx
📚 Learning: 2025-09-05T07:31:11.396Z
Learnt from: Zishan-7
Repo: peanutprotocol/peanut-ui PR: 1185
File: src/components/Claim/useClaimLink.tsx:14-0
Timestamp: 2025-09-05T07:31:11.396Z
Learning: In the peanut-ui codebase, `window.history.replaceState` is preferred over `router.replace` when immediate/synchronous URL parameter updates are required, as `router.replace` is asynchronous and doesn't guarantee instant URL changes that subsequent code can rely on. This pattern is used consistently across usePaymentInitiator.ts, Confirm.payment.view.tsx, and useClaimLink.tsx.

Applied to files:

  • src/app/(mobile-ui)/layout.tsx
  • src/app/[...recipient]/page.tsx
  • src/app/(mobile-ui)/qr/[code]/success/page.tsx
📚 Learning: 2025-09-12T09:06:22.741Z
Learnt from: Zishan-7
Repo: peanutprotocol/peanut-ui PR: 1207
File: src/components/Global/PostSignupActionManager/post-signup-action.consts.ts:0-0
Timestamp: 2025-09-12T09:06:22.741Z
Learning: In the peanut-ui codebase, redirect URLs are always stored as relative paths, never as absolute paths. This means regex patterns can safely anchor to `^\/` when matching stored redirect URLs.

Applied to files:

  • src/app/(mobile-ui)/layout.tsx
📚 Learning: 2024-10-23T09:38:27.670Z
Learnt from: jjramirezn
Repo: peanutprotocol/peanut-ui PR: 469
File: src/app/request/pay/page.tsx:32-64
Timestamp: 2024-10-23T09:38:27.670Z
Learning: In `src/app/request/pay/page.tsx`, if `linkRes` is not OK in the `generateMetadata` function, the desired behavior is to use the standard title and preview image without throwing an error.

Applied to files:

  • src/app/[...recipient]/page.tsx
  • src/app/(mobile-ui)/qr/[code]/success/page.tsx
📚 Learning: 2024-10-22T18:11:36.864Z
Learnt from: jjramirezn
Repo: peanutprotocol/peanut-ui PR: 469
File: src/app/request/pay/page.tsx:32-49
Timestamp: 2024-10-22T18:11:36.864Z
Learning: In `src/app/request/pay/page.tsx`, the `id` parameter is accessed via `searchParams.id` in the `generateMetadata` function.

Applied to files:

  • src/app/[...recipient]/page.tsx
📚 Learning: 2024-10-08T20:13:42.967Z
Learnt from: Hugo0
Repo: peanutprotocol/peanut-ui PR: 422
File: src/components/Request/Pay/Pay.tsx:113-123
Timestamp: 2024-10-08T20:13:42.967Z
Learning: In the `PayRequestLink` component (`src/components/Request/Pay/Pay.tsx`), when resolving ENS names, handle errors by displaying an appropriate error message to the user if the ENS cannot be resolved.

Applied to files:

  • src/app/[...recipient]/page.tsx
  • src/app/(mobile-ui)/qr/[code]/success/page.tsx
📚 Learning: 2025-05-02T19:14:25.010Z
Learnt from: kushagrasarathe
Repo: peanutprotocol/peanut-ui PR: 828
File: src/app/(mobile-ui)/request/[...username]/page.tsx:17-17
Timestamp: 2025-05-02T19:14:25.010Z
Learning: In Next.js App Router, catch-all routes (like `/request/[...username]`) will only match URLs with at least one parameter segment, ensuring that the page component only renders when there is at least one value in the params array.

Applied to files:

  • src/app/[...recipient]/page.tsx
  • src/constants/routes.ts
📚 Learning: 2025-05-13T10:05:24.057Z
Learnt from: kushagrasarathe
Repo: peanutprotocol/peanut-ui PR: 845
File: src/components/Request/link/views/Create.request.link.view.tsx:81-81
Timestamp: 2025-05-13T10:05:24.057Z
Learning: In the peanut-ui project, pages that handle request flows (like Create.request.link.view.tsx) are only accessible to logged-in users who will always have a username, making null checks for user?.user.username unnecessary in these contexts.

Applied to files:

  • src/app/[...recipient]/page.tsx
📚 Learning: 2025-07-24T10:57:15.315Z
Learnt from: Zishan-7
Repo: peanutprotocol/peanut-ui PR: 1000
File: src/components/og/ProfileCardOG.tsx:0-0
Timestamp: 2025-07-24T10:57:15.315Z
Learning: In `src/components/og/ProfileCardOG.tsx`, the scribble image should have an empty alt attribute (alt="") to prevent layout issues if the image fails to load. Since it's a decorative element positioned absolutely over the username text, showing alt text would interfere with the layout and username display.

Applied to files:

  • src/components/Profile/components/PublicProfile.tsx
📚 Learning: 2025-09-08T03:13:09.111Z
Learnt from: jjramirezn
Repo: peanutprotocol/peanut-ui PR: 1190
File: src/app/(mobile-ui)/qr-pay/page.tsx:156-176
Timestamp: 2025-09-08T03:13:09.111Z
Learning: In the peanut-ui mobile app, the `/qr-pay` route is only accessed through the DirectSendQR component which always includes the qrCode parameter in the URL when redirecting users to the QR pay page.

Applied to files:

  • src/app/(mobile-ui)/qr/[code]/success/page.tsx
📚 Learning: 2025-09-08T03:13:09.111Z
Learnt from: jjramirezn
Repo: peanutprotocol/peanut-ui PR: 1190
File: src/app/(mobile-ui)/qr-pay/page.tsx:156-176
Timestamp: 2025-09-08T03:13:09.111Z
Learning: In the peanut-ui mobile app, the `/qr-pay` route is only accessed through the DirectSendQR component which always includes the qrCode parameter in the URL when redirecting users to the QR pay page after scanning MERCADO_PAGO or PIX QR codes.

Applied to files:

  • src/app/(mobile-ui)/qr/[code]/success/page.tsx
📚 Learning: 2025-08-26T15:25:53.328Z
Learnt from: Zishan-7
Repo: peanutprotocol/peanut-ui PR: 1132
File: src/app/[...recipient]/client.tsx:394-397
Timestamp: 2025-08-26T15:25:53.328Z
Learning: In `src/components/Common/ActionListDaimoPayButton.tsx`, the `handleCompleteDaimoPayment` function should not display error messages to users when DB update fails because the Daimo payment itself has succeeded - showing errors would be confusing since the payment was successful.

Applied to files:

  • src/app/(mobile-ui)/qr/[code]/success/page.tsx
📚 Learning: 2025-08-07T12:53:50.946Z
Learnt from: Zishan-7
Repo: peanutprotocol/peanut-ui PR: 1072
File: src/app/(setup)/setup/page.tsx:173-175
Timestamp: 2025-08-07T12:53:50.946Z
Learning: In the peanut-ui setup flow at `src/app/(setup)/setup/page.tsx`, when handling unsupported scenarios, both device not supported and browser not supported cases should show the same "Unsupported browser" message using the `UnsupportedBrowserModal` component, rather than having distinct messaging for each scenario.

Applied to files:

  • src/app/(mobile-ui)/qr/[code]/success/page.tsx
📚 Learning: 2024-11-18T21:36:11.486Z
Learnt from: jjramirezn
Repo: peanutprotocol/peanut-ui PR: 535
File: src/components/Claim/Claim.tsx:142-146
Timestamp: 2024-11-18T21:36:11.486Z
Learning: In `src/components/Claim/Claim.tsx`, external calls like token price fetching and cross-chain details retrieval are already encapsulated within existing `try...catch` blocks, so additional error handling may be unnecessary.

Applied to files:

  • src/app/(mobile-ui)/qr/[code]/success/page.tsx
📚 Learning: 2024-12-11T10:13:22.806Z
Learnt from: jjramirezn
Repo: peanutprotocol/peanut-ui PR: 564
File: src/components/Request/Pay/Views/Initial.view.tsx:430-430
Timestamp: 2024-12-11T10:13:22.806Z
Learning: In the React TypeScript file `src/components/Request/Pay/Views/Initial.view.tsx`, when reviewing the `InitialView` component, do not flag potential issues with using non-null assertion `!` on the `slippagePercentage` variable, as handling undefined values in this context is considered out of scope.

Applied to files:

  • src/app/(mobile-ui)/qr/[code]/success/page.tsx
📚 Learning: 2025-08-22T07:28:32.281Z
Learnt from: Zishan-7
Repo: peanutprotocol/peanut-ui PR: 1104
File: src/components/Payment/PaymentForm/index.tsx:522-545
Timestamp: 2025-08-22T07:28:32.281Z
Learning: In `src/components/Payment/PaymentForm/index.tsx`, the `handleCompleteDaimoPayment` function is only for updating payment status in the backend after a successful Daimo payment. Payment success/failure is handled by Daimo itself, so try/catch error handling and error display are not needed for backend sync failures - users shouldn't see errors if payment succeeded but database update failed.

Applied to files:

  • src/app/(mobile-ui)/qr/[code]/success/page.tsx
📚 Learning: 2025-08-12T17:44:04.268Z
Learnt from: Zishan-7
Repo: peanutprotocol/peanut-ui PR: 1089
File: src/components/LandingPage/dropLink.tsx:35-42
Timestamp: 2025-08-12T17:44:04.268Z
Learning: In the Peanut UI project, opening the `/setup` route in a new tab from landing page CTAs is intentional design behavior to keep users on the marketing page while they start the setup process.

Applied to files:

  • src/app/(mobile-ui)/qr/[code]/success/page.tsx
🧬 Code graph analysis (5)
src/app/(mobile-ui)/layout.tsx (1)
src/constants/routes.ts (1)
  • PUBLIC_ROUTES_REGEX (60-60)
src/app/[...recipient]/page.tsx (3)
src/constants/routes.ts (1)
  • RESERVED_ROUTES (48-48)
src/lib/hosting/get-origin.ts (1)
  • getOrigin (3-16)
src/constants/general.consts.ts (1)
  • BASE_URL (52-52)
src/components/Global/ShareButton/index.tsx (1)
src/utils/general.utils.ts (1)
  • copyTextToClipboardWithFallback (393-419)
src/hooks/query/user.ts (1)
src/hooks/useGetDeviceType.ts (1)
  • useDeviceType (37-42)
src/app/(mobile-ui)/qr/[code]/success/page.tsx (7)
src/components/0_Bruddle/Toast.tsx (1)
  • useToast (111-117)
src/hooks/useRedirectQrStatus.ts (1)
  • useRedirectQrStatus (22-30)
src/components/Global/DirectSendQR/utils.ts (1)
  • BASE_URL (108-108)
src/utils/confetti.ts (1)
  • confettiPresets (70-84)
src/components/Global/PeanutLoading/index.tsx (1)
  • PeanutLoading (4-19)
src/components/Global/Icons/Icon.tsx (1)
  • Icon (209-218)
src/components/0_Bruddle/Button.tsx (1)
  • Button (78-269)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Deploy-Preview
🔇 Additional comments (6)
public/onesignal/OneSignalSDKWorker.js (1)

1-6: Documentation improvement approved.

The expanded comments clearly explain OneSignal's version pinning strategy for the v16 CDN channel, including automatic security updates and the self-hosting alternative. The reference link has been verified and is accessible.

src/hooks/query/user.ts (1)

9-9: LGTM! Clean telemetry enhancement.

The addition of device type tracking to the login metric is well-integrated. The useDeviceType hook is called correctly at the component level, and the device type is properly included in the analytics payload alongside the existing isPwa flag.

Also applies to: 13-13, 22-25

src/components/Global/Modal/index.tsx (1)

80-80: LGTM!

Adding border-0 to the base Dialog.Panel classes ensures consistent border removal across modal components. The change aligns with the ActionModal updates for unified modal presentation.

src/components/Global/ShareButton/index.tsx (1)

69-111: LGTM: Clipboard-first approach improves UX.

The new flow significantly improves user experience:

  • Clipboard copy happens first (lines 75-80), ensuring users always have the content even if the share dialog fails or isn't available
  • Immediate feedback via toast notification confirms the copy succeeded
  • Native share dialog is attempted as a bonus feature (lines 83-89)
  • Error handling gracefully distinguishes between user cancellation (AbortError) and actual failures (lines 94-110)
  • Fallback copy attempt (lines 99-106) only triggers if the initial copy failed

This approach works well across desktop (clipboard + optional share) and mobile (clipboard + share dialog) contexts.

src/app/(mobile-ui)/layout.tsx (1)

23-23: LGTM: Centralized routing constant usage.

The refactoring to use the imported PUBLIC_ROUTES_REGEX from the centralized routes configuration is clean and reduces duplication. Note that the regex itself has an issue flagged in src/constants/routes.ts (line 60) that should be addressed.

Also applies to: 79-79

src/app/[...recipient]/page.tsx (1)

177-182: LGTM: Reserved route guard is correctly implemented.

The guard properly prevents the catch-all route from handling reserved routes by calling notFound(). The code safely handles the undefined case since recipient is defaulted to [] on line 175.

@Hugo0
Copy link
Contributor Author

Hugo0 commented Nov 3, 2025

@coderabbitai review in depth.

Be especially vigilant towards breaking backwards functionality and risks of code.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

version pinned this

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Nov 3, 2025

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# First, let's see the redirects.json to understand static redirects
echo "=== Checking redirects.json ==="
cat redirects.json 2>/dev/null || echo "redirects.json not found"

echo -e "\n=== Checking for potential username conflicts with reserved routes ==="
# Check if there are any existing users or data that might use paths like /qr, /setup, etc.
rg -n -C2 'username.*["\']/(qr|setup|invite|claim)' --type=ts --type=tsx 2>/dev/null || echo "No obvious username conflicts found"

echo -e "\n=== Checking for hardcoded route references that might break ==="
rg -n -C2 '(/qr/|peanut\.me/qr)' --type=ts --type=tsx -g '!src/app/(mobile-ui)/qr/**' -g '!src/hooks/useRedirectQrStatus.ts' -g '!src/components/Global/DirectSendQR/**' -g '!src/constants/routes.ts'

echo -e "\n=== Checking JWT cookie handling patterns ==="
rg -n -C3 'Cookies\.get.*token|jwt' --type=ts --type=tsx | head -50

echo -e "\n=== Checking for existing tests ==="
fd -e test.ts -e test.tsx -e spec.ts -e spec.tsx | head -20

Length of output: 1629


🏁 Script executed:

#!/bin/bash
echo "=== Examining route constants in detail ==="
cat src/constants/routes.ts

echo -e "\n=== Checking catch-all route implementation ==="
cat src/app/[...recipient]/page.tsx

echo -e "\n=== Checking middleware matcher ==="
cat src/middleware.ts | grep -A 20 "matcher"

echo -e "\n=== Looking for environment variable usage in claim flow ==="
rg -n 'PEANUT_API_URL' src/app/\(mobile-ui\)/qr/

Length of output: 10872


🏁 Script executed:

#!/bin/bash
echo "=== Checking for redirect/navigation patterns that could be affected ==="
rg -n -C2 'router\.(push|replace).*recipient|navigate.*recipient' --type=ts --type=tsx

echo -e "\n=== Checking existing QR handling code ==="
cat src/components/Global/DirectSendQR/utils.ts

echo -e "\n=== Checking for URL validation/sanitization ==="
rg -n -C3 'new URL|sanitize|validate.*url' src/app/\(mobile-ui\)/qr/ src/components/Global/DirectSendQR/

echo -e "\n=== Checking authentication patterns in similar flows ==="
rg -n -C3 'fetch.*headers.*Authorization|Bearer.*token' --type=ts --type=tsx | head -40

Length of output: 10319


🏁 Script executed:

#!/bin/bash
echo "=== Examining full QR claim page implementation ==="
cat src/app/\(mobile-ui\)/qr/\[code\]/page.tsx

echo -e "\n=== Examining QR success page ==="
cat src/app/\(mobile-ui\)/qr/\[code\]/success/page.tsx

echo -e "\n=== Checking DirectSendQR component for redirect handling ==="
cat src/components/Global/DirectSendQR/index.tsx

Length of output: 36070


🏁 Script executed:

#!/bin/bash
echo "=== Checking for JWT cookie extraction patterns ==="
rg -n -C5 'js-cookie|Cookies\.' --type=ts | head -60

echo -e "\n=== Checking for existing auth/token utilities ==="
fd -t f 'auth|token' src/utils src/lib | head -20
cat src/utils/auth.ts 2>/dev/null || echo "No auth.ts found"

Length of output: 5272


🏁 Script executed:

#!/bin/bash
echo "=== Checking Invite page changes ==="
cat src/components/Invites/InvitesPage.tsx | grep -A 30 "useEffect"

echo -e "\n=== Checking useRedirectQrStatus hook implementation ==="
cat src/hooks/useRedirectQrStatus.ts

echo -e "\n=== Checking for error handling in claim flow ==="
rg -n 'catch|error' src/app/\(mobile-ui\)/qr/\[code\]/page.tsx

Length of output: 4094


🏁 Script executed:

#!/bin/bash
echo "=== Checking PUBLIC_ROUTES_REGEX usage in layout ==="
cat src/app/\(mobile-ui\)/layout.tsx | grep -B5 -A10 "PUBLIC_ROUTES"

echo -e "\n=== Checking if there are any references to usernames that could conflict ==="
rg -n '"qr"|"setup"|"home"|"claim"' --type=json -g 'test/**' -g 'mock/**' -g '**/*.test.*' 2>/dev/null | head -20

echo -e "\n=== Checking for existing route protection logic ==="
rg -n 'notFound|redirect.*setup' --type=ts | head -30

Length of output: 2493


🏁 Script executed:

#!/bin/bash
echo "=== Checking environment variables and constants ==="
cat src/constants/index.ts | grep -A2 -B2 "PEANUT_API"

echo -e "\n=== Looking for CSRF/security headers in fetch calls ==="
rg -n 'credentials.*include|mode.*cors' --type=ts | head -20

Length of output: 110


🏁 Script executed:

#!/bin/bash
echo "=== Checking for potential URL scheme validation issues ==="
rg -n -C3 'javascript:|data:|file:' --type=ts --type=tsx 2>/dev/null || echo "No obvious URL scheme checks found"

echo -e "\n=== Checking for existing usernames in database that might conflict ==="
# Look for any seeding, fixtures, or test data that might have problematic usernames
fd -e json -e ts -e sql seed fixture mock | head -10
rg -n '"qr"|"setup"|"home"|"history"|"settings"' --type=json -g '**/seed*' -g '**/fixture*' -g '**/mock*' 2>/dev/null | head -20

echo -e "\n=== Checking for httpOnly and SameSite cookie settings ==="
rg -n 'httpOnly|sameSite|SameSite|secure.*cookie' --type=ts | head -20

echo -e "\n=== Checking for existing rate limiting or throttling ==="
rg -n 'rateLimit|throttle|debounce' --type=ts | head -20

Length of output: 3753


🏁 Script executed:

#!/bin/bash
echo "=== Checking URL redirection security patterns ==="
cat src/app/\(mobile-ui\)/qr/\[code\]/page.tsx | grep -B5 -A10 "window.location.href\|router.push"

echo -e "\n=== Checking if there's any URL validation utility ==="
fd -e ts -e tsx url src/utils src/lib | head -10
cat src/utils/url.ts 2>/dev/null || cat src/lib/url.ts 2>/dev/null || echo "No URL utility found"

echo -e "\n=== Checking for existing security utilities or sanitization ==="
fd -e ts -e tsx sanitize security validate src/utils src/lib | head -10

Length of output: 3002


🏁 Script executed:

#!/bin/bash
echo "=== Checking how catch-all route handles paths ==="
cat src/app/\[...recipient\]/page.tsx | grep -A5 -B5 "RESERVED_ROUTES\|notFound"

echo -e "\n=== Looking for username validation or restrictions ==="
rg -n 'username.*validation|validateUsername|username.*regex|USERNAME' --type=ts | head -30

echo -e "\n=== Checking if backend API validates redirectUrl ==="
# Look for any backend validation logic or comments about security
rg -n 'redirectUrl|redirect.*url|target.*url' src/ | grep -i 'valid\|check\|sanit' | head -20

Length of output: 5010


🏁 Script executed:

#!/bin/bash
echo "=== Checking for test coverage ==="
fd -e test.ts -e test.tsx -e spec.ts -e spec.tsx . | wc -l
fd test | grep -E 'qr|redirect|claim' | head -20

echo -e "\n=== Checking for any existing E2E or integration tests ==="
fd -e ts -e tsx e2e cypress playwright | head -20

echo -e "\n=== Checking package.json for test scripts ==="
cat package.json | grep -A5 -B5 '"test"'

Length of output: 671


🏁 Script executed:

#!/bin/bash
echo "=== Examining URL sanitization utilities ==="
cat src/utils/general.utils.ts | grep -A30 "sanitizeRedirectURL"
cat src/utils/general.utils.ts | grep -A30 "getValidRedirectUrl"

echo -e "\n=== Checking if these utilities are used in the QR claim flow ==="
rg -n 'sanitizeRedirectURL|getValidRedirectUrl' src/app/\(mobile-ui\)/qr/ src/components/Global/DirectSendQR/

echo -e "\n=== Checking constants file for API URL ==="
cat src/constants/index.ts | head -50

Length of output: 3824


🏁 Script executed:

#!/bin/bash
echo "=== Checking DirectSendQR for security issues with redirect URL ==="
cat src/components/Global/DirectSendQR/index.tsx | grep -A20 -B5 "fetch.*qr.*code"

echo -e "\n=== Checking for XSS protection patterns ==="
rg -n 'dangerouslySetInnerHTML|innerHTML|eval\(' --type=ts --type=tsx 2>/dev/null | head -20

echo -e "\n=== Checking for CORS and credentials configuration ==="
cat src/app/\(mobile-ui\)/qr/\[code\]/page.tsx | grep -B3 -A10 "fetch"

Length of output: 664


🏁 Script executed:

#!/bin/bash
echo "=== Checking for existing username conflicts by looking at route examples ==="
rg -n "router\.push.*'/qr'|href.*'/qr'" --type=ts | head -20

echo -e "\n=== Checking if any documentation mentions reserved routes or username restrictions ==="
fd -e md README CONTRIBUTING docs | head -10
cat README.md 2>/dev/null | grep -i "username\|route\|reserved" | head -20
cat docs/ROUTES_CONFIG.md 2>/dev/null | head -50

Length of output: 1772


🏁 Script executed:

#!/bin/bash
echo "=== Looking for migration files or schema definitions ==="
fd -e prisma -e sql schema migration | head -20
cat prisma/schema.prisma 2>/dev/null | grep -A10 "model.*Redirect\|table.*redirect" || echo "No schema file found"

echo -e "\n=== Checking for rate limiting on backend endpoints ==="
rg -n 'rate.*limit|throttle' src/app/api/ | head -20

Length of output: 232


@Hugo0, I've completed an in-depth security and backwards compatibility review. I've identified several critical security vulnerabilities and backwards compatibility risks that need immediate attention:

🚨 Critical Security Issues

1. Open Redirect Vulnerability (HIGH SEVERITY)

The QR claim page accepts redirectUrl from the backend without any validation or sanitization:

Location: src/app/(mobile-ui)/qr/[code]/page.tsx:38-56

if (redirectQrData.claimed && redirectQrData.redirectUrl) {
    try {
        const url = new URL(redirectQrData.redirectUrl)
        const isExternal = url.origin !== window.location.origin

        if (isExternal) {
            window.location.href = redirectQrData.redirectUrl  // ⚠️ UNSAFE
        } else {
            router.push(invitePath)
        }
    } catch (error) {
        window.location.href = redirectQrData.redirectUrl  // ⚠️ UNSAFE - fallback also vulnerable
    }
}

Attack Scenario:

  1. Attacker compromises backend or exploits a backend vulnerability
  2. Backend returns redirectUrl: "https://evil.com/phishing"
  3. User gets redirected to malicious site that looks like Peanut
  4. User enters credentials on fake site

The codebase HAS a sanitization utility (sanitizeRedirectURL) but it's NOT being used here!

Same issue in DirectSendQR component (src/components/Global/DirectSendQR/index.tsx:234-244):

if (data.claimed && data.redirectUrl) {
    redirectUrl = data.redirectUrl  // ⚠️ No validation
}

Fix Required:

// Import the existing utility
import { sanitizeRedirectURL } from '@/utils'

// In the useEffect:
if (redirectQrData.claimed && redirectQrData.redirectUrl) {
    const sanitizedUrl = sanitizeRedirectURL(redirectQrData.redirectUrl)
    
    if (!sanitizedUrl) {
        // Reject external redirects
        console.error('Rejecting unsafe redirect URL:', redirectQrData.redirectUrl)
        router.push('/home')
        return
    }
    
    router.push(sanitizedUrl)
    return
}

2. Missing Backend Validation Confirmation

While the frontend should validate, the backend MUST be the primary defense. I cannot see the backend code, but please confirm:

  • Does the backend validate targetUrl before storing in the Redirect table?
  • Does it restrict URLs to same-origin only (peanut.me domain)?
  • Does it prevent javascript:, data:, file: schemes?
  • Does it validate the format: https://peanut.me/invite?code={USERNAME}INVITESYOU?

Recommended backend validation (if not already implemented):

// Backend: POST /qr/:code/claim
const ALLOWED_REDIRECT_PATTERN = /^\/invite\?code=[A-Z0-9]+INVITESYOU$/i;

function validateRedirectUrl(targetUrl: string): boolean {
    try {
        const url = new URL(targetUrl, 'https://peanut.me');
        
        // Only allow same-origin
        if (url.origin !== 'https://peanut.me') {
            return false;
        }
        
        // Only allow invite paths
        const path = url.pathname + url.search;
        if (!ALLOWED_REDIRECT_PATTERN.test(path)) {
            return false;
        }
        
        return true;
    } catch {
        return false;
    }
}

3. JWT Exposure via js-cookie (MEDIUM SEVERITY)

The JWT token is accessed via Cookies.get('jwt-token') which means it's readable by JavaScript:

Location: src/app/(mobile-ui)/qr/[code]/page.tsx:75-80

const response = await fetch(`${PEANUT_API_URL}/qr/${code}/claim`, {
    method: 'POST',
    headers: {
        'Content-Type': 'application/json',
        Authorization: `Bearer ${Cookies.get('jwt-token')}`,  // ⚠️ Accessible to JS
    },
    body: JSON.stringify({}),
})

Concern: If the cookie is not httpOnly, it's vulnerable to XSS attacks. While I see that the server-side API routes DO set httpOnly:

// src/app/api/peanut/user/login-user/route.ts:39
httpOnly: true,
sameSite: 'strict',

However, js-cookie typically can't read httpOnly cookies. This suggests either:

  1. The JWT is stored in a non-httpOnly cookie for client-side access, OR
  2. There's a mismatch between server-side and client-side cookie handling

Action Required: Please verify the JWT cookie configuration. If it's not httpOnly, this is a vulnerability.

4. No CSRF Protection

The claim endpoint uses only Bearer token authentication without CSRF tokens. While this is acceptable for API endpoints, ensure the backend validates:

  • Token authenticity
  • Token hasn't been revoked
  • Request origin (if applicable)

⚠️ Backwards Compatibility Risks

1. Username Conflicts with Reserved Routes (HIGH RISK)

Problem: If any existing user has username matching reserved routes, their profile will break:

// src/constants/routes.ts
export const DEDICATED_ROUTES = [
    'qr', 'api', 'setup', 'home', 'history', 'settings', 'points',
    'claim', 'pay', 'request', 'invite', 'support', 'dev',
]

Scenario:

  • User with username "qr" exists
  • Their profile URL: peanut.me/qr
  • After this PR: Catches the /qr/:path* route instead → profile is inaccessible
  • Catch-all route calls notFound() for reserved routes

Fix Required:

// Before deploying, run this check:
const RESERVED_USERNAMES = ['qr', 'api', 'setup', ...];
// Query database for existing users with these usernames
// If found, either:
// 1. Force rename these users (with notification)
// 2. Add grandfather clause to allow existing usernames
// 3. Change the QR route to /qr-codes/ or /redirect-qr/

🏁 Script executed:

#!/bin/bash
echo "=== Checking if there's a backend repository reference ==="
rg -n 'peanut-api-ts|backend|api.*repo' README.md package.json .github/ 2>/dev/null | head -10

echo -e "\n=== Creating a script to check for username conflicts (for your backend) ==="
cat > /tmp/check_username_conflicts.sql << 'EOF'
-- Run this on your backend database to check for conflicts
-- Adjust table/column names as needed

SELECT username, COUNT(*) as count
FROM users
WHERE LOWER(username) IN (
    'qr', 'api', 'setup', 'home', 'history', 'settings', 'points',
    'claim', 'pay', 'request', 'invite', 'support', 'dev',
    'docs', 'packet', 'create-packet', 'batch', 'raffle', 
    'pioneers', 'pints', 'events', 'foodie'
)
GROUP BY username
ORDER BY username;

-- If any results are returned, those users will have broken profiles after this PR
EOF

cat /tmp/check_username_conflicts.sql

Length of output: 662


2. QR Scanner URL Normalization Changes (MEDIUM RISK)

Location: src/components/Global/DirectSendQR/utils.ts:135-139

The URL recognition logic changed to normalize URLs before comparison:

// NEW CODE:
const normalizedData = data.toLowerCase().replace(/^https?:\/\/(www\.)?/, '')
const normalizedBaseUrl = BASE_URL.toLowerCase().replace(/^https?:\/\/(www\.)?/, '')

if (normalizedData.startsWith(normalizedBaseUrl) || normalizedData.startsWith('peanut.me/')) {
    return EQrType.PEANUT_URL
}

Risk: Existing QR codes with different case or protocol variations might behave differently:

  • PEANUT.ME/user → now recognized (previously might not be)
  • HTTP://peanut.me/user → now recognized
  • www.peanut.me/user → now recognized

While this seems like an improvement, it changes behavior for existing QR codes in the wild. Users who had workarounds for case-sensitive matching might see different behavior.

Mitigation: Monitor for unexpected QR scan behaviors after deployment.

3. Invite Page Auto-Redirect Changes UX (MEDIUM RISK)

Location: src/components/Invites/InvitesPage.tsx:77-88

useEffect(() => {
    if (!user?.user || !inviteCodeData || isLoading) {
        return
    }

    // If user has app access and invite is valid, redirect to inviter's profile
    if (user.user.hasAppAccess && inviteCodeData.success && inviteCodeData.username) {
        router.push(`/${inviteCodeData.username}`)
    }
}, [user, inviteCodeData, isLoading, router])

Change: Logged-in users with app access are immediately redirected to the inviter's profile instead of seeing the invite page.

Breaking Change Scenarios:

  1. Sharing invite links: Users who want to share their invite code can no longer view the invite page themselves
  2. Testing invites: Developers testing invite flows will be auto-redirected
  3. Deep links with redirect_uri: The redirect_uri parameter is ignored for logged-in users, breaking expected behavior

Example:

Before: peanut.me/invite?code=ALICEINVITESYOU&redirect_uri=/pay/bob
Logged-in user sees invite page, can click claim

After: peanut.me/invite?code=ALICEINVITESYOU&redirect_uri=/pay/bob
Logged-in user immediately redirected to /alice (redirect_uri ignored!)

Recommendation: Add a query parameter to bypass auto-redirect for testing/sharing:

const searchParams = useSearchParams()
const bypassRedirect = searchParams.get('preview') === 'true'

useEffect(() => {
    if (!user?.user || !inviteCodeData || isLoading || bypassRedirect) {
        return
    }
    
    // ... rest of redirect logic
}, [user, inviteCodeData, isLoading, router, bypassRedirect])

4. PUBLIC_ROUTES_REGEX Modification (LOW-MEDIUM RISK)

Location: src/constants/routes.ts:46

export const PUBLIC_ROUTES_REGEX = /^\/(request\/pay|claim|pay\/.+$|support|invite|dev|qr)/

Added: |qr at the end

Impact: The /qr route is now public (no authentication required). This is intentional for the claim flow, but ensure:

  • Unauthenticated access to /qr/:code is safe (users are redirected to /setup - ✅ confirmed)
  • No authenticated endpoints under /qr/ that should be protected

Note: The regex has a potential issue: pay\/.+$ uses $ (end of string) but it's in the middle of the alternation. This might not match as intended.

Test these URLs:

  • /pay/alice → should match
  • /pay/alice/extra → might not match due to $ placement

Suggested fix:

export const PUBLIC_ROUTES_REGEX = /^\/(request\/pay|claim|pay\/|support|invite|dev|qr)/
// Removed .+$ from pay, let it match any pay/ path

🧪 Missing Test Coverage

Zero tests found for:

  • QR claim flow
  • QR success page
  • useHoldToClaim hook
  • useRedirectQrStatus hook
  • DirectSendQR redirect handling

Critical scenarios needing tests:

  1. Claiming already-claimed QR codes
  2. Race conditions (two users claiming simultaneously)
  3. Invalid QR codes
  4. Network errors during claim
  5. URL redirect validation
  6. Hold-to-claim timer accuracy

🔒 Additional Security Concerns

1. Error Message Information Disclosure

Location: src/app/(mobile-ui)/qr/[code]/page.tsx:92-94

} catch (err: any) {
    console.error('Error claiming QR:', err)
    setError(err.message || 'Failed to claim QR code. Please try again.')
}

Backend error messages are directly displayed to users. Ensure backend doesn't leak sensitive information in error messages (e.g., "User ID 12345 already claimed QR code ABC123").

2. No Rate Limiting (Frontend)

The hold-to-claim mechanic (1.5s) provides some rate limiting, but there's no additional throttling on the claim button. A malicious user could:

  1. Modify the holdDuration in dev tools
  2. Spam the claim endpoint

Ensure backend has rate limiting on POST /qr/:code/claim.

3. QR Code Enumeration

Location: src/hooks/useRedirectQrStatus.ts:23-27

return useQuery({
    queryKey: ['redirect-qr-status', code],
    queryFn: () => fetchRedirectQrStatus(code!),
    enabled: !!code,
    staleTime: 0, // Always fetch fresh data
})

The status endpoint is public (no auth required). An attacker could enumerate all possible QR codes to:

  • Find unclaimed codes
  • Build a database of username mappings

Recommendations:

  1. Backend should rate-limit the GET /qr/:code endpoint
  2. Consider requiring authentication for status checks
  3. Add honeypot/monitoring for suspicious enumeration patterns

📋 Pre-Deployment Checklist

Critical (Must Do):

  • Fix open redirect vulnerability (add sanitizeRedirectURL calls)
  • Verify backend URL validation (restrict to invite paths only)
  • Check for username conflicts (run the SQL query above)
  • Verify JWT cookie is httpOnly
  • Add backend rate limiting (GET /qr/:code and POST /qr/:code/claim)

Important (Should Do):

  • Test PUBLIC_ROUTES_REGEX edge cases
  • Add bypass parameter for invite auto-redirect
  • Add integration tests for QR claim flow
  • Verify error messages don't leak sensitive data
  • Monitor QR code enumeration attempts

Nice to Have:

  • Add E2E tests with Playwright/Cypress
  • Document QR code claiming process
  • Add Sentry monitoring for claim errors
  • Consider adding CSRF tokens for extra security

🎯 Recommended Fixes (Code Provided)

Priority 1: Fix Open Redirect

// src/app/(mobile-ui)/qr/[code]/page.tsx
import { sanitizeRedirectURL } from '@/utils'

useEffect(() => {
    if (isCheckingStatus || !redirectQrData) return

    if (redirectQrData.claimed && redirectQrData.redirectUrl) {
        // SECURITY: Validate redirect URL before using it
        const sanitizedUrl = sanitizeRedirectURL(redirectQrData.redirectUrl)
        
        if (!sanitizedUrl) {
            console.error('Unsafe redirect URL rejected:', redirectQrData.redirectUrl)
            router.push('/home')
            return
        }
        
        // All URLs from backend should be internal, use Next.js router
        router.push(sanitizedUrl)
        return
    }

    if (!user) {
        saveRedirectUrl()
        router.push('/setup')
    }
}, [isCheckingStatus, redirectQrData, user, router])

Priority 2: Fix DirectSendQR

// src/components/Global/DirectSendQR/index.tsx (line ~234)
import { sanitizeRedirectURL } from '@/utils'

if (path.startsWith('/qr/')) {
    const redirectQrCode = path.substring(4)
    
    try {
        const response = await fetch(`${process.env.NEXT_PUBLIC_PEANUT_API_URL}/qr/${redirectQrCode}`)
        const data = await response.json()

        if (data.claimed && data.redirectUrl) {
            // SECURITY: Validate redirect URL
            const sanitizedUrl = sanitizeRedirectURL(data.redirectUrl)
            if (sanitizedUrl) {
                redirectUrl = sanitizedUrl
            } else {
                console.error('Unsafe redirect URL rejected:', data.redirectUrl)
                redirectUrl = `/qr/${redirectQrCode}`
            }
        } else {
            redirectUrl = `/qr/${redirectQrCode}`
        }
    } catch (error) {
        console.error('Error checking redirect QR:', error)
        redirectUrl = `/qr/${redirectQrCode}`
    }
}

Priority 3: Add Username Conflict Prevention

// Backend: Add to user registration/username update validation
const RESERVED_USERNAMES = new Set([
    'qr', 'api', 'setup', 'home', 'history', 'settings', 'points',
    'claim', 'pay', 'request', 'invite', 'support', 'dev',
    'docs', 'packet', 'create-packet', 'batch', 'raffle',
    'pioneers', 'pints', 'events', 'foodie'
]);

function isUsernameReserved(username: string): boolean {
    return RESERVED_USERNAMES.has(username.toLowerCase());
}

// In user creation/update:
if (isUsernameReserved(newUsername)) {
    throw new Error('This username is reserved. Please choose another.');
}

Overall Assessment:

  • Security Risk: HIGH (open redirect vulnerability)
  • Backwards Compatibility Risk: MEDIUM (username conflicts, UX changes)
  • Code Quality: GOOD (well-structured, reusable hooks)
  • Test Coverage: LOW (no tests for new functionality)

The feature implementation is solid, but the security vulnerabilities must be fixed before merging. The backwards compatibility issues should be addressed to prevent breaking existing users.

Would you like me to create follow-up issues for the lower-priority items, or would you prefer to address all critical items in this PR first?


🧠 Learnings used
Learnt from: Hugo0
Repo: peanutprotocol/peanut-ui PR: 852
File: src/hooks/useTransactionHistory.ts:177-239
Timestamp: 2025-05-14T21:16:06.369Z
Learning: When reviewing code, pay attention to and resolve CodeRabbit comments as they can contain useful refactoring suggestions and insights.

Learnt from: Hugo0
Repo: peanutprotocol/peanut-ui PR: 594
File: src/components/Request/Pay/Views/Initial.view.tsx:188-213
Timestamp: 2024-12-31T15:08:15.640Z
Learning: Always resolve coderabbit comments when reviewing code, since they can contain useful suggestions.

Learnt from: Hugo0
Repo: peanutprotocol/peanut-ui PR: 823
File: src/context/kernelClient.context.tsx:79-86
Timestamp: 2025-04-29T19:36:38.121Z
Learning: When Hugo0 asks to "resolve coderabbit comments", they want to acknowledge the comment without necessarily implementing the suggested changes, as the current implementation might be intentional for their specific use case.

Learnt from: Hugo0
Repo: peanutprotocol/peanut-ui PR: 942
File: src/components/AddMoney/consts/index.ts:2151-2162
Timestamp: 2025-06-30T10:44:08.048Z
Learning: Hugo0 often agrees with refactoring suggestions but defers implementation due to time constraints, preferring to track improvements as follow-up issues when they're part of larger architectural changes.

@Hugo0
Copy link
Contributor Author

Hugo0 commented Nov 3, 2025

@coderabbitai review latest changes

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Nov 3, 2025

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
echo "=== Getting recent commits on feat/qr-sheets branch ==="
git log --oneline -10 feat/qr-sheets

echo -e "\n=== Getting the latest commit details ==="
git log -1 --stat feat/qr-sheets

echo -e "\n=== Checking if critical files were modified recently ==="
git log -3 --name-only --pretty=format:"%h %ad | %s" --date=relative feat/qr-sheets | head -40

Length of output: 742


🏁 Script executed:

#!/bin/bash
echo "=== Checking current state of QR claim page for redirect security ==="
grep -A 20 "redirectQrData.redirectUrl" src/app/\(mobile-ui\)/qr/\[code\]/page.tsx

echo -e "\n=== Checking DirectSendQR for redirect security ==="
grep -A 15 "data.redirectUrl" src/components/Global/DirectSendQR/index.tsx

echo -e "\n=== Checking if sanitizeRedirectURL is being used ==="
rg -n "sanitizeRedirectURL" src/app/\(mobile-ui\)/qr/ src/components/Global/DirectSendQR/

Length of output: 2579


🏁 Script executed:

#!/bin/bash
echo "=== Checking JWT cookie handling ==="
grep -A 5 "Cookies.get.*jwt" src/app/\(mobile-ui\)/qr/\[code\]/page.tsx

echo -e "\n=== Checking public routes configuration ==="
cat src/constants/routes.ts | grep -A 5 "PUBLIC_ROUTES_REGEX"

echo -e "\n=== Checking invite page auto-redirect logic ==="
grep -A 10 "hasAppAccess" src/components/Invites/InvitesPage.tsx

Length of output: 1232


🏁 Script executed:

#!/bin/bash
echo "=== Getting diff of the most recent commits ==="
git log --oneline -5 feat/qr-sheets | head -5

echo -e "\n=== Checking what files changed in the last commit ==="
git diff HEAD~1 HEAD --name-only

echo -e "\n=== Getting detailed diff of critical files if they changed ==="
git diff HEAD~1 HEAD -- src/app/\(mobile-ui\)/qr/\[code\]/page.tsx src/components/Global/DirectSendQR/index.tsx 2>/dev/null || echo "These files were not changed in the last commit"

Length of output: 2552


🏁 Script executed:

#!/bin/bash
echo "=== Checking changes to catch-all recipient page ==="
git diff HEAD~1 HEAD -- src/app/\[...recipient\]/page.tsx

echo -e "\n=== Checking changes to routes.ts ==="
git diff HEAD~1 HEAD -- src/constants/routes.ts

echo -e "\n=== Checking changes to general.utils.ts ==="
git diff HEAD~1 HEAD -- src/utils/general.utils.ts

Length of output: 2368


🏁 Script executed:

#!/bin/bash
echo "=== Checking if generateInviteCodeLink is secure ==="
rg -A 20 "export.*generateInviteCodeLink" src/utils/

echo -e "\n=== Verifying current sanitizeRedirectURL implementation ==="
rg -A 20 "sanitizeRedirectURL" src/utils/general.utils.ts

Length of output: 3213


🏁 Script executed:

#!/bin/bash
echo "=== Checking if there are any test files added ==="
fd -e test.ts -e test.tsx -e spec.ts -e spec.tsx . | grep -E 'qr|redirect|claim' || echo "No test files found for QR functionality"

echo -e "\n=== Checking if backend validation comments or TODOs were added ==="
rg -n "TODO|FIXME|SECURITY|VALIDATE" src/app/\(mobile-ui\)/qr/ src/components/Global/DirectSendQR/ | head -20

Length of output: 160


@Hugo0, I've reviewed the latest changes. Here's the status:

✅ Fixed Issues

Good work on these:

  1. PUBLIC_ROUTES_REGEX regex fix - Removed the misplaced $ from pay\/.+$pay\/.+ (Line 60 in routes.ts)
  2. Safe navigation - Added ?. operators in catch-all page to prevent undefined access
  3. Documentation - Added warning comment about keeping invite code generation in sync between frontend/backend

🚨 CRITICAL ISSUE STILL UNFIXED

The open redirect vulnerability is still present and must be fixed before merging.

Problem Locations:

1. QR Claim Page (src/app/(mobile-ui)/qr/[code]/page.tsx:38-56)

The code still redirects to redirectQrData.redirectUrl without validation:

if (redirectQrData.claimed && redirectQrData.redirectUrl) {
    try {
        const url = new URL(redirectQrData.redirectUrl)
        const isExternal = url.origin !== window.location.origin

        if (isExternal) {
            window.location.href = redirectQrData.redirectUrl  // ⚠️ UNSAFE
        } else {
            const invitePath = `${url.pathname}${url.search}`
            router.push(invitePath)
        }
    } catch (error) {
        window.location.href = redirectQrData.redirectUrl  // ⚠️ UNSAFE
    }
    return
}

2. DirectSendQR Component (src/components/Global/DirectSendQR/index.tsx:234-237)

if (data.claimed && data.redirectUrl) {
    redirectUrl = data.redirectUrl  // ⚠️ No validation
}

The Fix (Copy-Paste Ready):

For src/app/(mobile-ui)/qr/[code]/page.tsx (around line 38):

if (redirectQrData.claimed && redirectQrData.redirectUrl) {
    // SECURITY: Validate redirect URL before using it
    const sanitizedUrl = sanitizeRedirectURL(redirectQrData.redirectUrl)
    
    if (!sanitizedUrl) {
        console.error('Unsafe redirect URL rejected:', redirectQrData.redirectUrl)
        router.push('/home')
        return
    }
    
    // Use the sanitized URL (always internal after validation)
    router.push(sanitizedUrl)
    return
}

Don't forget to add the import at the top:

import { saveRedirectUrl, generateInviteCodeLink, sanitizeRedirectURL } from '@/utils'

For src/components/Global/DirectSendQR/index.tsx (around line 234):

if (data.claimed && data.redirectUrl) {
    // SECURITY: Validate redirect URL
    const sanitizedUrl = sanitizeRedirectURL(data.redirectUrl)
    if (sanitizedUrl) {
        redirectUrl = sanitizedUrl
    } else {
        console.error('Unsafe redirect URL rejected:', data.redirectUrl)
        redirectUrl = `/qr/${redirectQrCode}`
    }
}

Add the import:

import { sanitizeRedirectURL } from '@/utils'

📊 Attack Scenario Without This Fix

  1. Attacker finds a way to modify backend data or exploits a backend vulnerability
  2. Backend returns: redirectUrl: "https://phishing-site.com/fake-peanut-login"
  3. User scans QR code → Frontend blindly redirects to malicious site
  4. User thinks they're on peanut.me, enters credentials → compromised

Your existing sanitizeRedirectURL utility already handles this perfectly - it rejects external URLs and only allows same-origin paths. You just need to use it in these two places.

⚠️ Other Outstanding Issues

Still Not Addressed:

  1. Username conflicts check - Need to verify no existing users have reserved route names as usernames
  2. Test coverage - No tests for QR claim flow
  3. JWT cookie validation - Need to confirm it's httpOnly (likely fine, but should verify)
  4. Backend validation - Need to confirm backend validates targetUrl format (can't verify from frontend code)

Lower Priority:

  • Rate limiting (backend should handle)
  • Error message sanitization (backend should handle)
  • Invite page auto-redirect bypass parameter (nice to have)

🎯 Recommendation

Before merging:

  1. MUST FIX: Add sanitizeRedirectURL to both locations above (5 minutes)
  2. SHOULD VERIFY: Run this query on your database to check username conflicts:
SELECT username FROM users 
WHERE LOWER(username) IN ('qr', 'api', 'setup', 'home', 'history', 'settings', 'points', 'claim', 'pay', 'request', 'invite', 'support', 'dev')
LIMIT 10;

After merging:
3. Add tests for QR claim flow
4. Monitor for any issues with existing users

The redirect validation is a 5-minute fix that prevents a critical security vulnerability. The sanitizeRedirectURL utility is already in your codebase and works perfectly - it just needs to be called before using redirectUrl values.

Would you like me to create an issue to track the test coverage and username conflict check, or do you want to address those in this PR as well?


🧠 Learnings used
Learnt from: kushagrasarathe
Repo: peanutprotocol/peanut-ui PR: 845
File: src/components/Request/link/views/Create.request.link.view.tsx:81-81
Timestamp: 2025-05-13T10:05:24.057Z
Learning: In the peanut-ui project, pages that handle request flows (like Create.request.link.view.tsx) are only accessible to logged-in users who will always have a username, making null checks for user?.user.username unnecessary in these contexts.

Learnt from: Zishan-7
Repo: peanutprotocol/peanut-ui PR: 1207
File: src/components/Global/PostSignupActionManager/post-signup-action.consts.ts:0-0
Timestamp: 2025-09-12T09:06:22.741Z
Learning: In the peanut-ui codebase, redirect URLs are always stored as relative paths, never as absolute paths. This means regex patterns can safely anchor to `^\/` when matching stored redirect URLs.

Learnt from: Hugo0
Repo: peanutprotocol/peanut-ui PR: 852
File: src/hooks/useTransactionHistory.ts:177-239
Timestamp: 2025-05-14T21:16:06.369Z
Learning: When reviewing code, pay attention to and resolve CodeRabbit comments as they can contain useful refactoring suggestions and insights.

Learnt from: Hugo0
Repo: peanutprotocol/peanut-ui PR: 458
File: src/components/Offramp/Confirm.view.tsx:96-96
Timestamp: 2024-10-18T08:54:22.142Z
Learning: In the `src/components/Offramp/Confirm.view.tsx` file, it's acceptable to include crass or informal language in code comments.

Learnt from: Hugo0
Repo: peanutprotocol/peanut-ui PR: 594
File: src/components/Request/Pay/Views/Initial.view.tsx:188-213
Timestamp: 2024-12-31T15:08:15.640Z
Learning: Always resolve coderabbit comments when reviewing code, since they can contain useful suggestions.

Learnt from: Hugo0
Repo: peanutprotocol/peanut-ui PR: 942
File: src/components/AddMoney/consts/index.ts:2151-2162
Timestamp: 2025-06-30T10:44:08.048Z
Learning: Hugo0 often agrees with refactoring suggestions but defers implementation due to time constraints, preferring to track improvements as follow-up issues when they're part of larger architectural changes.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 1d1e570 and ee4db34.

📒 Files selected for processing (4)
  • src/app/(mobile-ui)/qr/[code]/page.tsx (1 hunks)
  • src/app/[...recipient]/page.tsx (2 hunks)
  • src/constants/routes.ts (1 hunks)
  • src/utils/general.utils.ts (1 hunks)
✅ Files skipped from review due to trivial changes (1)
  • src/utils/general.utils.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/app/[...recipient]/page.tsx
  • src/constants/routes.ts
🧰 Additional context used
🧠 Learnings (7)
📓 Common learnings
Learnt from: jjramirezn
Repo: peanutprotocol/peanut-ui PR: 1190
File: src/app/(mobile-ui)/qr-pay/page.tsx:156-176
Timestamp: 2025-09-08T03:13:09.111Z
Learning: In the peanut-ui mobile app, the `/qr-pay` route is only accessed through the DirectSendQR component which always includes the qrCode parameter in the URL when redirecting users to the QR pay page.
Learnt from: jjramirezn
Repo: peanutprotocol/peanut-ui PR: 1190
File: src/app/(mobile-ui)/qr-pay/page.tsx:156-176
Timestamp: 2025-09-08T03:13:09.111Z
Learning: In the peanut-ui mobile app, the `/qr-pay` route is only accessed through the DirectSendQR component which always includes the qrCode parameter in the URL when redirecting users to the QR pay page after scanning MERCADO_PAGO or PIX QR codes.
Learnt from: Hugo0
Repo: peanutprotocol/peanut-ui PR: 942
File: src/components/AddMoney/consts/index.ts:2151-2162
Timestamp: 2025-06-30T10:44:08.048Z
Learning: Hugo0 often agrees with refactoring suggestions but defers implementation due to time constraints, preferring to track improvements as follow-up issues when they're part of larger architectural changes.
📚 Learning: 2025-09-08T03:13:09.111Z
Learnt from: jjramirezn
Repo: peanutprotocol/peanut-ui PR: 1190
File: src/app/(mobile-ui)/qr-pay/page.tsx:156-176
Timestamp: 2025-09-08T03:13:09.111Z
Learning: In the peanut-ui mobile app, the `/qr-pay` route is only accessed through the DirectSendQR component which always includes the qrCode parameter in the URL when redirecting users to the QR pay page.

Applied to files:

  • src/app/(mobile-ui)/qr/[code]/page.tsx
📚 Learning: 2025-09-08T03:13:09.111Z
Learnt from: jjramirezn
Repo: peanutprotocol/peanut-ui PR: 1190
File: src/app/(mobile-ui)/qr-pay/page.tsx:156-176
Timestamp: 2025-09-08T03:13:09.111Z
Learning: In the peanut-ui mobile app, the `/qr-pay` route is only accessed through the DirectSendQR component which always includes the qrCode parameter in the URL when redirecting users to the QR pay page after scanning MERCADO_PAGO or PIX QR codes.

Applied to files:

  • src/app/(mobile-ui)/qr/[code]/page.tsx
📚 Learning: 2025-09-05T07:31:11.396Z
Learnt from: Zishan-7
Repo: peanutprotocol/peanut-ui PR: 1185
File: src/components/Claim/useClaimLink.tsx:14-0
Timestamp: 2025-09-05T07:31:11.396Z
Learning: In the peanut-ui codebase, `window.history.replaceState` is preferred over `router.replace` when immediate/synchronous URL parameter updates are required, as `router.replace` is asynchronous and doesn't guarantee instant URL changes that subsequent code can rely on. This pattern is used consistently across usePaymentInitiator.ts, Confirm.payment.view.tsx, and useClaimLink.tsx.

Applied to files:

  • src/app/(mobile-ui)/qr/[code]/page.tsx
📚 Learning: 2025-05-22T15:38:48.586Z
Learnt from: kushagrasarathe
Repo: peanutprotocol/peanut-ui PR: 869
File: src/app/(mobile-ui)/withdraw/page.tsx:82-88
Timestamp: 2025-05-22T15:38:48.586Z
Learning: The country-specific withdrawal route exists at src/app/(mobile-ui)/withdraw/[...country]/page.tsx and renders the AddWithdrawCountriesList component with flow="withdraw".

Applied to files:

  • src/app/(mobile-ui)/qr/[code]/page.tsx
📚 Learning: 2025-07-24T13:26:10.290Z
Learnt from: Hugo0
Repo: peanutprotocol/peanut-ui PR: 1014
File: src/components/Claim/Link/Initial.view.tsx:413-413
Timestamp: 2025-07-24T13:26:10.290Z
Learning: In the peanut-ui repository, the change from `${SQUID_API_URL}/route` to `${SQUID_API_URL}/v2/route` in src/components/Claim/Link/Initial.view.tsx was a typo fix, not an API migration, as the codebase was already using Squid API v2.

Applied to files:

  • src/app/(mobile-ui)/qr/[code]/page.tsx
📚 Learning: 2025-08-12T17:44:04.268Z
Learnt from: Zishan-7
Repo: peanutprotocol/peanut-ui PR: 1089
File: src/components/LandingPage/dropLink.tsx:35-42
Timestamp: 2025-08-12T17:44:04.268Z
Learning: In the Peanut UI project, opening the `/setup` route in a new tab from landing page CTAs is intentional design behavior to keep users on the marketing page while they start the setup process.

Applied to files:

  • src/app/(mobile-ui)/qr/[code]/page.tsx
🧬 Code graph analysis (1)
src/app/(mobile-ui)/qr/[code]/page.tsx (9)
src/context/authContext.tsx (1)
  • useAuth (192-198)
src/hooks/useRedirectQrStatus.ts (1)
  • useRedirectQrStatus (22-30)
src/utils/general.utils.ts (2)
  • saveRedirectUrl (786-790)
  • generateInviteCodeLink (892-897)
src/constants/general.consts.ts (1)
  • PEANUT_API_URL (43-47)
src/hooks/useHoldToClaim.ts (1)
  • useHoldToClaim (34-200)
src/utils/perk.utils.ts (1)
  • getShakeClass (13-27)
src/components/Global/PeanutLoading/index.tsx (1)
  • PeanutLoading (4-19)
src/components/Global/Icons/Icon.tsx (1)
  • Icon (209-218)
src/components/0_Bruddle/Button.tsx (1)
  • Button (78-269)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Deploy-Preview
🔇 Additional comments (3)
src/app/(mobile-ui)/qr/[code]/page.tsx (3)

68-108: Verify backend implements rate limiting and token validation for claim endpoint.

The claim handler makes an authenticated POST request without client-side rate limiting or additional abuse prevention. While the hold-to-claim UX provides some friction, ensure the backend enforces:

  1. Rate limiting on POST /qr/:code/claim (e.g., max 5 attempts per minute per user)
  2. JWT validation and revocation checks
  3. Idempotency handling for duplicate claims

To confirm backend protection is in place, ask the backend team or review the API implementation. Additionally, monitor for abuse patterns in production.

As per PR objectives, this is a known concern that should be addressed on the backend before deployment.


38-58: CRITICAL: Open redirect vulnerability - validate and sanitize redirectUrl before navigation.

The redirectUrl from the backend is used directly for navigation without client-side validation or sanitization. An attacker who compromises the backend or exploits a backend validation gap could redirect users to phishing sites.

Attack scenario:

  1. Attacker sets malicious targetUrl in backend (e.g., https://evil.com/phishing)
  2. User scans QR code
  3. Lines 47 or 55 redirect user to attacker-controlled site

Apply this diff to sanitize the redirect URL:

         // If QR is already claimed, redirect to the target URL
         if (redirectQrData.claimed && redirectQrData.redirectUrl) {
+            // Sanitize the redirect URL to prevent open redirect attacks
+            const sanitizedUrl = sanitizeRedirectURL(redirectQrData.redirectUrl)
+            
+            if (!sanitizedUrl) {
+                // Invalid or unsafe URL - redirect to a safe fallback
+                router.push('/home')
+                return
+            }
+            
             try {
-                const url = new URL(redirectQrData.redirectUrl)
+                const url = new URL(sanitizedUrl)

                 // Check if external redirect (different domain)
                 const isExternal = url.origin !== window.location.origin

                 if (isExternal) {
                     // External redirect - use full URL navigation
-                    window.location.href = redirectQrData.redirectUrl
+                    window.location.href = sanitizedUrl
                 } else {
                     // Internal redirect - extract path for Next.js router (better UX, no page reload)
                     const invitePath = `${url.pathname}${url.search}` // e.g., /invite?code=XYZINVITESYOU
                     router.push(invitePath)
                 }
             } catch (error) {
-                // Fallback for invalid URLs
-                window.location.href = redirectQrData.redirectUrl
+                // Fallback to safe page for invalid URLs
+                router.push('/home')
             }
             return
         }

Additionally, ensure backend validation is enforced as primary defense (restrict targetUrl to allowed patterns, reject javascript:, data:, file: schemes).

⛔ Skipped due to learnings
Learnt from: Zishan-7
Repo: peanutprotocol/peanut-ui PR: 1207
File: src/components/Global/PostSignupActionManager/post-signup-action.consts.ts:0-0
Timestamp: 2025-09-12T09:06:22.741Z
Learning: In the peanut-ui codebase, redirect URLs are always stored as relative paths, never as absolute paths. This means regex patterns can safely anchor to `^\/` when matching stored redirect URLs.
Learnt from: Zishan-7
Repo: peanutprotocol/peanut-ui PR: 1185
File: src/components/Claim/useClaimLink.tsx:14-0
Timestamp: 2025-09-05T07:31:11.396Z
Learning: In the peanut-ui codebase, `window.history.replaceState` is preferred over `router.replace` when immediate/synchronous URL parameter updates are required, as `router.replace` is asynchronous and doesn't guarantee instant URL changes that subsequent code can rely on. This pattern is used consistently across usePaymentInitiator.ts, Confirm.payment.view.tsx, and useClaimLink.tsx.
Learnt from: jjramirezn
Repo: peanutprotocol/peanut-ui PR: 1190
File: src/app/(mobile-ui)/qr-pay/page.tsx:156-176
Timestamp: 2025-09-08T03:13:09.111Z
Learning: In the peanut-ui mobile app, the `/qr-pay` route is only accessed through the DirectSendQR component which always includes the qrCode parameter in the URL when redirecting users to the QR pay page.

87-87: I need to examine how the authentication actually works in the QR code page to understand if this is dead code or if there's an alternative mechanism:


The original review comment is based on an incorrect premise. While the server automatically includes the httpOnly cookie in subsequent HTTP requests to the same domain, this cookie remains invisible to any JavaScript running on the page. The code review correctly configured the JWT cookie with httpOnly: true in multiple server endpoints (e.g., src/app/api/peanut/user/login-user/route.ts, src/app/api/peanut/user/register-user/route.ts), which means a cookie with the HttpOnly attribute can't be accessed by JavaScript—therefore the concern about XSS token extraction via Cookies.get('jwt-token') does not apply. The authentication token is secure and protected from XSS attacks through the httpOnly flag.

Likely an incorrect or invalid review comment.

Copy link
Contributor

@jjramirezn jjramirezn left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking good, lets go

@Hugo0 Hugo0 merged commit efaa823 into peanut-wallet-dev Nov 3, 2025
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants