Merge mobile release (native iOS/Android) into main#2393
Conversation
Two native passkey reliability fixes behind the May-18 Play rejection and a known multi-account signing bug. Silent "Set it up" no-op (rejection cause): - checkPasskeySupport() now actually queries the native plugin's isSupported() in Capacitor instead of assuming support - SetupPasskey re-checks on tap and always surfaces an actionable message — the button can never silently do nothing - clearer Android NotAllowedError help copy (Google account / Play Services vs private-mode/cancel) Multi-account signing (re-applies closed PR #2189): - createNativeSignMessageCallback pins allowCredentials to the kernel's own authenticatorId when the caller supplies none, so a second peanut.me passkey on the device can't be substituted. Additive and native-only; requires a 2-account real-device smoke test.
Two native passkey reliability fixes behind the May-18 Play rejection and a known multi-account signing bug. Silent "Set it up" no-op (rejection cause): - checkPasskeySupport() now actually queries the native plugin's isSupported() in Capacitor instead of assuming support - SetupPasskey re-checks on tap and always surfaces an actionable message — the button can never silently do nothing - clearer Android NotAllowedError help copy (Google account / Play Services vs private-mode/cancel) Multi-account signing (re-applies closed PR #2189): - createNativeSignMessageCallback pins allowCredentials to the kernel's own authenticatorId when the caller supplies none, so a second peanut.me passkey on the device can't be substituted. Additive and native-only; requires a 2-account real-device smoke test.
Native-enablement fixes that were sitting uncommitted in the working tree. - android/build.gradle: force Java 17 across all modules (capacitor-android compiles at 21 → "compiled with a more recent Java" Gradle failures). Java 17 is the Capacitor 8 / AGP baseline. - card-comparison.ts: drop the `'use server'` directive. Server Actions can't exist in a static export (output: 'export') and would break the native build. The file only does a public dolarapi.com fetch + arithmetic (no secrets), so it's safe to run client-side; its dep getCurrencyPrice is not a server action. - AndroidManifest + res/xml/network_security_config.xml: permit cleartext ONLY to 10.0.2.2 / localhost so the emulator can reach a local backend. The network config takes precedence on API 24+, so production https traffic is unaffected. Note: capacitor.build.gradle / capacitor.settings.gradle / capacitor-passkey.xml are cap-sync-generated and intentionally not committed here.
Native-enablement fixes that were sitting uncommitted in the working tree. - android/build.gradle: force Java 17 across all modules (capacitor-android compiles at 21 → "compiled with a more recent Java" Gradle failures). Java 17 is the Capacitor 8 / AGP baseline. - card-comparison.ts: drop the `'use server'` directive. Server Actions can't exist in a static export (output: 'export') and would break the native build. The file only does a public dolarapi.com fetch + arithmetic (no secrets), so it's safe to run client-side; its dep getCurrencyPrice is not a server action. - AndroidManifest + res/xml/network_security_config.xml: permit cleartext ONLY to 10.0.2.2 / localhost so the emulator can reach a local backend. The network config takes precedence on API 24+, so production https traffic is unaffected. Note: capacitor.build.gradle / capacitor.settings.gradle / capacitor-passkey.xml are cap-sync-generated and intentionally not committed here.
Audit-driven security fixes (peanut-ui): - MoreInfo: remove the `html` prop / dangerouslySetInnerHTML sink. The only caller (ValidatedInput) passed help-text that's always rendered safely now; the sink was a latent XSS vector if user-controlled text ever reached it. - MermaidRenderer (dev): render caught errors via textContent instead of interpolating the (untrusted) diagram source into innerHTML. - vercel.json: add baseline security headers (nosniff, X-Frame-Options SAMEORIGIN, Referrer-Policy, HSTS) and a clickjacking CSP (frame-ancestors 'self'; object-src 'none'; base-uri 'self'). A full script-src allow-list is deferred to a report-only rollout. - PeanutDebug: add a NODE_ENV==='production' early return so the hardcoded harness private key + localStorage signer writes are dead-code-eliminated from prod bundles, not just skipped at runtime.
Audit-driven security fixes (peanut-ui): - MoreInfo: remove the `html` prop / dangerouslySetInnerHTML sink. The only caller (ValidatedInput) passed help-text that's always rendered safely now; the sink was a latent XSS vector if user-controlled text ever reached it. - MermaidRenderer (dev): render caught errors via textContent instead of interpolating the (untrusted) diagram source into innerHTML. - vercel.json: add baseline security headers (nosniff, X-Frame-Options SAMEORIGIN, Referrer-Policy, HSTS) and a clickjacking CSP (frame-ancestors 'self'; object-src 'none'; base-uri 'self'). A full script-src allow-list is deferred to a report-only rollout. - PeanutDebug: add a NODE_ENV==='production' early return so the hardcoded harness private key + localStorage signer writes are dead-code-eliminated from prod bundles, not just skipped at runtime.
Fill the edge-to-edge status-bar inset on the setup/onboarding flow with the brand periwinkle (secondary-3) and pad by env(safe-area-inset-top) so the feedback ribbon isn't jammed under the status icons on Android 15 (targetSdk 36, edge-to-edge forced). No-op on web and pre-edge-to-edge Android.
Fill the edge-to-edge status-bar inset on the setup/onboarding flow with the brand periwinkle (secondary-3) and pad by env(safe-area-inset-top) so the feedback ribbon isn't jammed under the status icons on Android 15 (targetSdk 36, edge-to-edge forced). No-op on web and pre-edge-to-edge Android.
Everything needed for Play review beyond the passkey fixes. Reviewer/demo mode (entered via the `demo` invite code, native-only): - utils/reviewer.ts gate; pre-filled balance + history (constants/demo-data.ts) overlaid in useWallet / useTransactionHistory - KYC skipped (useCapabilities, useSumsubKycFlow) - money-movement simulated at the execution primitives (useSpendBundle, useZeroDev) — no chain, no funds; safe on mainnet - flag set on `demo` invite (services/invites.ts), cleared on logout Build rot guard + versioning: - native-build.js fails loudly on server-only routes not covered by the disable list (route handlers / force-dynamic) - android versionCode derives from git commit count (env-overridable), versionName from package.json; never below 2 Release flow: - scripts/native-release.sh (pnpm native:release) derives version and runs build -> cap sync -> signed bundleRelease - docs/NATIVE-RELEASE.md runbook Native plugins (low-risk polish): - @capacitor/haptics (success buzz on send), @capacitor/keyboard (resize) Setup status-bar safe zone: - (setup)/layout.tsx fills the edge-to-edge status-bar inset with the brand periwinkle so the feedback ribbon isn't jammed under the status icons
Everything needed for Play review beyond the passkey fixes. Reviewer/demo mode (entered via the `demo` invite code, native-only): - utils/reviewer.ts gate; pre-filled balance + history (constants/demo-data.ts) overlaid in useWallet / useTransactionHistory - KYC skipped (useCapabilities, useSumsubKycFlow) - money-movement simulated at the execution primitives (useSpendBundle, useZeroDev) — no chain, no funds; safe on mainnet - flag set on `demo` invite (services/invites.ts), cleared on logout Build rot guard + versioning: - native-build.js fails loudly on server-only routes not covered by the disable list (route handlers / force-dynamic) - android versionCode derives from git commit count (env-overridable), versionName from package.json; never below 2 Release flow: - scripts/native-release.sh (pnpm native:release) derives version and runs build -> cap sync -> signed bundleRelease - docs/NATIVE-RELEASE.md runbook Native plugins (low-risk polish): - @capacitor/haptics (success buzz on send), @capacitor/keyboard (resize) Setup status-bar safe zone: - (setup)/layout.tsx fills the edge-to-edge status-bar inset with the brand periwinkle so the feedback ribbon isn't jammed under the status icons
- rename reviewer.ts→demo.ts; isReviewerMode→isDemoMode, etc. - hard-stop in kernelClient: in demo mode sendUserOperation returns a fake hash and never submits — covers every flow (send/recover/session-key), not just the simulated primitives. Guarantees no real transaction in demo mode. - trim verbose comments.
- rename reviewer.ts→demo.ts; isReviewerMode→isDemoMode, etc. - hard-stop in kernelClient: in demo mode sendUserOperation returns a fake hash and never submits — covers every flow (send/recover/session-key), not just the simulated primitives. Guarantees no real transaction in demo mode. - trim verbose comments.
…setup The synthetic demo user resolves async via useUserQuery, so there is a brief window where user is null. Three redirect paths bounced the session back to setup before DEMO_USER settled. Guard each with isDemoMode(): - (mobile-ui) layout: skip the /setup redirect (loading gate holds instead) - useAccountSetupRedirect: never redirect a demo session to /setup/finish - LandingPageCapacitorGate: cold-start routes a demo session to /home
…setup The synthetic demo user resolves async via useUserQuery, so there is a brief window where user is null. Three redirect paths bounced the session back to setup before DEMO_USER settled. Guard each with isDemoMode(): - (mobile-ui) layout: skip the /setup redirect (loading gate holds instead) - useAccountSetupRedirect: never redirect a demo session to /setup/finish - LandingPageCapacitorGate: cold-start routes a demo session to /home
Manage the release ops gaps: - .github/workflows/android-release.yml: tag/dispatch-triggered job that decodes the upload keystore from CI secrets, builds a signed AAB via native:release (versionCode = run_number), and uploads to a Play track. Removes the one-laptop release dependency; gate via a `production` GitHub Environment. - docs/NATIVE-RELEASE.md: rewritten for current reality — Node 22 / JDK 17 toolchain, branch merge order, full local-dev setup (AirPlay :5001, engineering stubs, perk key, 10.0.2.2 cleartext, demo invite), keystore secret-manager + recovery + assetlinks coupling, Capgo prod channel / staged rollout / rollback, and the CI secrets inventory.
- android-release.yml: write the full production NEXT_PUBLIC_* set into .env.production.local (identical block to ios-release.yml); PEANUT_API_URL now sourced from a repo var. CAPACITOR_BUILD/IS_NATIVE_BUILD/GIT_COMMIT_HASH are auto-baked by next.config.native.js, so they're omitted. - NATIVE-RELEASE.md §11: iOS pipeline now lives on feat/ci-ios; replace the stale 'in progress' stub with a pointer to docs/NATIVE-RELEASE-IOS.md.
- .github/workflows/ios-release.yml: build the static export, cap sync ios, then import the Apple Distribution cert (apple-actions/import-codesign-certs), install the provisioning profile from a base64 secret, xcodebuild archive/exportArchive with manual signing (CURRENT_PROJECT_VERSION = run number), and upload via apple-actions/upload-testflight-build. IPA uploaded as an artifact. - docs/NATIVE-RELEASE-IOS.md: pipeline overview, one-time signing-material setup, cert/profile rotation, manual App Store promotion, and the iOS secrets table. - Mirrors android-release.yml's no-fastlane style; no Ruby toolchain. Stacks on feat/native-review-readiness (needs scripts/native-build.js); independent of the Android CI branch.
cap add ios + plugins via SPM, Info.plist usage strings (camera/photos/FaceID), associated-domains entitlement (webcredentials/applinks peanut.me), bundle me.peanut.wallet. Simulator build pending an SPM artifact-cache clear.
The inviter-username gates and the signup field fired their validation lookup 750ms after any non-empty input, so typing a single character produced a misleading "No Peanut member with that username" / "must be at least 4 characters" error before the handle could possibly be valid. Add an optional `shouldValidate` predicate to ValidatedInput: when it fails, the field is held in the neutral "changing" state (no lookup, no error UI, Next disabled) instead of being marked invalid. Apply it on both invite gates and the signup field, gated on `>= USERNAME_MIN_LENGTH` (4, mirroring the backend username regex).
The inviter-username gates and the signup field fired their validation lookup 750ms after any non-empty input, so typing a single character produced a misleading "No Peanut member with that username" / "must be at least 4 characters" error before the handle could possibly be valid. Add an optional `shouldValidate` predicate to ValidatedInput: when it fails, the field is held in the neutral "changing" state (no lookup, no error UI, Next disabled) instead of being marked invalid. Apply it on both invite gates and the signup field, gated on `>= USERNAME_MIN_LENGTH` (4, mirroring the backend username regex).
…user) + formatted demo history
Note: how to get the CodeRabbit review running on this PRCodeRabbit keeps skipping this PR ("Too many files: 172, limit 150"). For anyone wondering, a new PR won't help and excluding images won't help either — here's why and what actually fixes it: Why it skips. The diff has 201 files; CodeRabbit's default filters already exclude the 29 images/lockfile ( Why the fix in this branch isn't working yet. This branch adds a What to do (needs CodeRabbit dashboard access):
Fallback if repo YAML only loads from the default branch: merge the This also permanently keeps future mobile PRs (which always carry Xcode/Gradle churn) under the review limit. |
|
Tip For best results, initiate chat on the files or code changes. 🧩 Analysis chain⚙️ CodeRabbit configuration inspected Length of output: 5263
So your two proposed fixes are both correct:
Once either change is made, re-trigger with ✏️ Learnings added
|
…e workflows Fixes the CodeQL findings on this PR: android/ios/capgo release workflows ran with default GITHUB_TOKEN permissions (none of them write to the repo, so contents: read suffices) and pulled third-party actions by mutable tag (pnpm/action-setup, setup-xcode, import-codesign-certs, upload-testflight-build), now pinned to commit SHAs.
…nning native-webauthn.ts is the most security-critical native file and had no direct tests. Covers: DER ECDSA signature parsing and low-S malleability normalization, SPKI public-key coordinate extraction and authenticator-id hashing, the zerodev response tuple encoding (responseTypeLocation, RIP-7212 precompile flag), challenge/rpId wiring, message format handling, and the multi-account credential pinning fix (pin used when no allow-list, caller list never overridden, empty list falls back to pin).
test(native): cover the WebAuthn bridge — DER parsing + credential pinning
kushagrasarathe
left a comment
There was a problem hiding this comment.
Review summary: holding this before merge to dev
I did a deep review across the auth/signing, demo-mode, native-infra/OTA/CI, and web-conflict surfaces, looking at security, regressions, and code quality.
Good news first. No web regressions. All 12 documented conflict resolutions are correct, main's fixes all survived, the api-fetch.ts web path is byte-identical to dev, the signing core is sound, and demo mode is correctly native-only and sandboxed. This is solid work.
That said, I'm not merging as-is. The OTA pipeline can push arbitrary JS into the wallet WebView, and right now its gating is weaker than the store-release path sitting next to it. Blockers are below.
Blockers (must fix before merge)
1. OTA bundles have no provenance signing. capacitor.config.ts:27-36
CapacitorUpdater has no publicKey. Capgo's checksum and sessionKey come from the same server response as the bundle URL, so they only verify transport integrity, not authorship. Anyone with CAPGO_API_KEY or access to the Capgo account can push JS that runs inside the wallet, which means full control of the passkey/signing UI, recipient addresses, and amounts, all without going through store review. That's RCE-equivalent for a money app.
Fix: enable Capgo E2E signing (npx @capgo/cli key create, add publicKey, sign bundles on upload).
2. OTA auto-deploys to production on merge to main with no approval gate. .github/workflows/capgo-deploy.yml:2-4
on: push: branches: [main, dev], and main maps to the production channel with no environment: reviewer gate, unlike android-release.yml and ios-release.yml. One bad or malicious merge ships straight to devices. Combined with #1, one merge equals arbitrary code on every install.
Fix: add environment: Production (with required reviewers) to the deploy job. Ideally make the production channel workflow_dispatch-only and leave staging on auto.
3. RECORD_AUDIO permission with no justification. android/app/src/main/AndroidManifest.xml:95
QR scanning is camera-only, and FOREGROUND_SERVICE_MICROPHONE is explicitly removed right beside it. This looks like a leftover from the Capacitor camera template. It triggers Play data-safety scrutiny and widens the attack surface for no reason.
Fix: delete the line.
4. enableDemoMode() on a web-reachable path is missing the isCapacitor() guard. src/services/invites.ts:50-51
This fires from web pages (InvitesPage, JoinWaitlistPage), unlike the native path which gates on isCapacitor(). It's inert today since isDemoMode() re-checks Capacitor, but it still writes the demo localStorage key on web, and it's the one spot where the web-inert guarantee falls apart if Capacitor detection ever regresses.
Fix: if (res.data?.success && isCapacitor() && isDemoInviteCode(inviteCode))
Non-blocking, worth folding in while you're here
- Sentry native capture at 100% plus console capture, scrub depth 10 to 15 (
sentry.utils.ts:256,instrumentation-client.ts). Please confirmisSensitiveKeycoversauthToken,jwt,sessionKey,privateKey,mnemonic, andAuthorizationso no logged object leaks a secret. - Dead code:
resetDemoBalance()(demo-balance.ts:45, no callers) and the unusedchallengevar (native-webauthn.ts:159).
Please confirm (off-repo, can't verify from the diff)
- Capgo
productionchannel is device-locked and not yet serving real installs - Whether account-level bundle signing is already enabled (would soften #1)
-
CAPGO_API_KEYscope. Prefer an upload-only key, not full-account. - Prod web Vercel project does not set
NEXT_PUBLIC_CAPACITOR_BUILD=true(the one flag that makes demo mode reachable on web) -
pnpm typecheck && npm test && npm run buildgreen on the merged tree (the PR claims 1863 tests pass, just want it confirmed in CI)
Once #1 through #4 are addressed and the checklist is cleared, I'm happy to re-review and land it on dev for staging. #2394 (native-webauthn tests) looks good and i have merged it.
- capgo: enable E2E bundle signing (publicKey in capacitor.config, sign CI upload via --key-data-v2) - capgo-deploy: production is workflow_dispatch-only + Production environment gate; dev auto-ships to staging - android: drop unjustified RECORD_AUDIO permission - invites: gate enableDemoMode() behind isCapacitor() on the web-reachable validate path - demo-balance: self-healing 7-day per-device TTL reset + tests - native-webauthn: remove unused challenge var and orphaned helper
|
Thanks for the thorough review — agree on all four blockers. Here's what I've changed. Quick context on the two OTA items (#1, #2): that pipeline came in with the original Capgo integration rather than this mobile-release branch, so these are hardening the existing setup rather than anything new introduced here — good catches regardless, and addressed below. Blockers1. OTA bundle signing ✅ Enabled Capgo E2E signing. Generated the RSA keypair and added the 2. OTA auto-deploy gate ✅ Went with the stronger option: dropped 3. 4. Non-blocking5. 6. Unused 7. Sentry — leaving as-is for now; we'll tune the capture/scrub settings in a follow-up.
|
Match the web's tracesSampleRate on the Capacitor Sentry init; error capture (sampleRate) stays at 1.0.
kushagrasarathe
left a comment
There was a problem hiding this comment.
approved
one qn, on the capgo workflow before it goes further: environment: Production is set at the job level, so it applies to every run including the dev to staging auto-deploy, not just production. if the Production environment has required reviewers, doesn't that mean staging pushes also pause for manual approval? that seems to contradict the "only dev auto-ships to staging" intent in the comment above it
was that intentional, or should the gate only apply to the production workflow_dispatch run? if the latter, we could make the environment conditional, something like:
environment: ${{ (github.event_name == 'workflow_dispatch' && github.event.inputs.channel == 'production') && 'Production' || '' }}…nto-main # Conflicts: # src/components/Global/TokenSelector/TokenSelector.tsx # src/hooks/wallet/useSpendBundle.ts
Job-level `environment: Production` applied to every run, so the dev→staging auto-push would also wait on the environment's required reviewers. Resolve the environment conditionally — Production only for a workflow_dispatch run targeting the production channel, empty otherwise — so staging keeps auto-shipping.
|
@kushagrasarathe updated according to your comment |
kushagrasarathe
left a comment
There was a problem hiding this comment.
good changes, lgtm
Two interacting defects made 'Select new method' unusable when saved bank accounts exist: 1. Crypto click navigated to /withdraw/crypto before an amount was set. That page's no-amount guard bounces straight back to /withdraw, and since da943d6 (mobile-release, merged to dev via peanutprotocol#2393) its unmount cleanup calls resetWithdrawFlow(), wiping selectedMethod and showAllWithdrawMethods — landing the user on the saved-accounts list instead of the amount input. Set the method in context only (same contract as bank-country selection); the withdraw page navigates to /withdraw/crypto after Continue, with the amount set. 2. The default-view effect re-ran on every user refetch (window focus, 4s pending-rail poll — each dispatches a fresh user object into redux) and unconditionally forced the saved-accounts view, yanking an open country list back after a few seconds. Latch the default view once per mount; refetches now only sync the accounts list.
What
Lands
feat/mobile-releaseintodev(for staging validation before the regular dev → main release) via an integration branch: 179 commits of native/mobile work (Capacitor iOS + Android platforms, TestFlight/Play CI release pipelines, native push via OneSignal adapters, demo/reviewer mode, passkey fixes, security hardening) merged with the ~370 commits main has gained since the branches diverged on June 19.origin/mainis already merged into this branch, so the diff below is exactly the mobile-release changes plus conflict resolutions — nothing from main is re-reviewed here.Conflict resolutions (20 files)
useNotifications/ OneSignal: kept the branch's platform-adapter architecture (module-level store, web + native adapters). Ported main's notification-click → PostHog UTM attribution into it: newonNotificationClickon the adapter interface, implemented in bothweb.adapter.ts(react-onesignal) andnative.adapter.ts(Capacitor plugin), registered in the hook with main's exact capture logic.stage >= 2, notPROCESSINGstatus) after the branch's copy was synced.PEANUTMANasset rename) — branch copies were earlier syncs from main.campaign-maps, dev routes regex, transaction-predicates test: took main's newer versions for the same reason.restrictToRhinois active and the popular list is empty, the fallback offers USDC on Arbitrum — flagging in case that interaction matters.sentry.utils: union — branch's/invites/validate400 skip + main'sqr-payment/init400/422 skip.Verification
pnpm typecheck✅pnpm test✅ 129 suites / 1863 testspnpm linthas ~1048 pre-existing problems (non-blocking in CI pertests.yml); none introduced by the resolutionsMerge instructions — please read before merging
Base is
devon purpose: this is a big change set, so it goes through staging first and reaches production through the normal dev → main release train. Bothorigin/mainandorigin/devare merged into this branch, so the diff is exactly the mobile-release changes plus conflict resolutions, and merging it into dev also carries the pending main → dev back-merge commits.Merge with a merge commit, NOT squash. Squash would break merge tracking:
feat/mobile-releasekeeps living as the TestFlight release branch, and after a squash git no longer knows the histories are joined — every future sync between it and main/dev would re-conflict on the same ~139 overlapping files, over and over. With a true merge commit, git records the join point and future syncs only deal with new changes. The same applies to the dev → main release PR that later ships this: merge commit, not squash.After this reaches
main: mergemainback intofeat/mobile-releaseso the branches converge.