Conversation
platform detection (isCapacitor, getApiBaseUrl, getPlatform) and native passkey signing extracted from poc into reusable modules.
- hardcode rpId to peanut.me in capacitor context - attach native signing callback on android (uses capacitor-webauthn plugin) - ios uses existing browser webauthn via wkwebview (no plugin needed) - skip browser-level passkey checks in capacitor (handled natively)
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
WalkthroughThis PR adds Capacitor support for WebAuthn/passkey flows by introducing platform detection utilities, native signing callbacks, and conditional bypass logic that forces passkey support when running under Capacitor instead of performing browser capability checks. Changes
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes 🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 inconclusive)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 7
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@src/hooks/usePasskeySupport.ts`:
- Around line 34-40: The current early return in usePasskeySupport (inside the
isCapacitor() branch) force-sets setBrowserSupported(true),
setConditionalMediationSupported(true), and setIsSupported(true), which
suppresses the UnsupportedBrowserModal even when native setup is incomplete;
instead, detect native capability more conservatively: remove the unconditional
true assignments and call a validation function (or check existing context
helpers) that verifies the native plugin/associated-domain wiring (e.g., probe
the Android passkey plugin or iOS associated-domains API) and only set
setBrowserSupported, setConditionalMediationSupported, and setIsSupported to
true when that probe succeeds; otherwise set them false so passkeySupportContext
and UnsupportedBrowserModal can surface the unsupported-native setup to users.
In `@src/hooks/useZeroDev.ts`:
- Around line 89-93: Persisted WebAuthn keys lose their function-valued
signMessageCallback (so Android native signing breaks); to fix, when restoring a
stored webAuthnKey on Android native reattach the native signing callback by
calling createNativeSignMessageCallback(rpId) and assigning it to
webAuthnKey.signMessageCallback before using or saving the key; specifically,
ensure the restore/hydration code path that reads the cookie (the code in
kernelClient.context.tsx that restores WEB_AUTHN_COOKIE_KEY) and any place that
loads webAuthnKey from storage re-applies webAuthnKey.signMessageCallback =
createNativeSignMessageCallback(rpId) (and likewise do this wherever webAuthnKey
is deserialized), so the native callback persists across restarts.
In `@src/utils/capacitor.ts`:
- Around line 68-75: The bundler can fail because the dynamic import of
'@capacitor/browser' inside isCapacitor() (the try { const mod = await
import('@capacitor/browser'); await mod.Browser.open({ url }) } catch {
window.location.href = url }) still requires the package to be present at bundle
time; add "@capacitor/browser" to package.json under "optionalDependencies" (not
"dependencies") so it’s declared but non-fatal when absent, then
reinstall/update the lockfile (pnpm install / update pnpm-lock.yaml) so the
package is recorded and builds that reference the dynamic import succeed while
keeping the runtime fallback.
- Around line 6-7: The isCapacitor() function currently checks window.Capacitor
truthiness; change it to use Capacitor.isNativePlatform() instead (i.e., return
Capacitor.isNativePlatform()). Ensure the Capacitor symbol is imported (for
example import { Capacitor } from '@capacitor/core' or the existing Capacitor
export used in the project) so isCapacitor() calls Capacitor.isNativePlatform()
directly and will only return true on native iOS/Android platforms.
In `@src/utils/native-webauthn.ts`:
- Around line 184-187: The code currently decodes cred.response.clientDataJSON
with atob after only swapping -/_ which can fail due to missing padding; replace
that logic in native-webauthn by decoding cred.response.clientDataJSON with
base64URLToBytes(...) and then new TextDecoder().decode(...) to get
clientDataJSON, then search for the marker before converting to BigInt: compute
beforeType by using clientDataJSON.lastIndexOf('"type":"webauthn.get"') and
check it is >= 0 (or throw/handle) before calling BigInt(beforeType) so you
avoid invalid BigInt conversions; update any uses of atob and the previous
beforeType assignment accordingly.
- Around line 6-7: The code imports p256 from '@noble/curves' in native-webauthn
(import { p256 } from '@noble/curves/p256'), but that API was removed in v2 and
'@noble/curves' is not a direct dependency; add an explicit pinned dependency to
package.json (e.g. "@noble/curves": "^1.9.7") so the v1 API remains available
and dependency resolution won't upgrade to v2.
In `@src/utils/passkeyPreflight.ts`:
- Around line 33-45: The current isCapacitor() branch unconditionally sets
isSupported: true and green diagnostics; instead, detect actual native support
and surface a warning/false when missing: call whatever native plugin presence
check your app uses (e.g., the Capacitor passkey plugin accessor) and verify
Android plugin availability (rather than relying only on
/android/i.test(navigator.userAgent)) and iOS associated-domains configuration,
then populate diagnostics.hasPublicKeyCredential, isHttps, isAndroid, and rpId
based on those real checks and set isSupported: false and a descriptive warning
when the native plugin or associated domains are absent; keep the same
diagnostics shape so consumers like SetupPasskey.tsx can show the warning.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 0fed1d9f-8dcd-487e-a0d9-03b66d29c437
📒 Files selected for processing (5)
src/hooks/usePasskeySupport.tssrc/hooks/useZeroDev.tssrc/utils/capacitor.tssrc/utils/native-webauthn.tssrc/utils/passkeyPreflight.ts
| // in capacitor, passkeys are handled natively (android via plugin, ios via WKWebView) | ||
| // skip browser-level checks since they may report false negatives in a webview | ||
| if (isCapacitor()) { | ||
| setBrowserSupported(true) | ||
| setConditionalMediationSupported(true) | ||
| setIsSupported(true) | ||
| return |
There was a problem hiding this comment.
This bypass hides unsupported native setups from the UI.
src/context/passkeySupportContext.tsx feeds these flags into src/components/Global/UnsupportedBrowserModal/index.tsx. Forcing browserSupported, conditionalMediationSupported, and isSupported to true means missing Android plugin wiring or iOS associated-domain setup will never show the modal and users only discover the problem after auth/signing fails.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/hooks/usePasskeySupport.ts` around lines 34 - 40, The current early
return in usePasskeySupport (inside the isCapacitor() branch) force-sets
setBrowserSupported(true), setConditionalMediationSupported(true), and
setIsSupported(true), which suppresses the UnsupportedBrowserModal even when
native setup is incomplete; instead, detect native capability more
conservatively: remove the unconditional true assignments and call a validation
function (or check existing context helpers) that verifies the native
plugin/associated-domain wiring (e.g., probe the Android passkey plugin or iOS
associated-domains API) and only set setBrowserSupported,
setConditionalMediationSupported, and setIsSupported to true when that probe
succeeds; otherwise set them false so passkeySupportContext and
UnsupportedBrowserModal can surface the unsupported-native setup to users.
| // on android native, attach the native signing callback so zerodev | ||
| // uses the capacitor-webauthn plugin instead of browser WebAuthn api | ||
| if (isAndroidNative()) { | ||
| webAuthnKey.signMessageCallback = createNativeSignMessageCallback(rpId) | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
printf "== Persisted WebAuthn key readers/writers ==\n"
rg -n -C3 --type ts --type tsx '\bWEB_AUTHN_COOKIE_KEY\b|["'"'"']web-authn-key["'"'"']|\bsaveToCookie\s*\(|\bgetFromCookie\s*\('
printf "\n== Native callback attachment points ==\n"
rg -n -C3 --type ts --type tsx '\bsignMessageCallback\b|\bcreateNativeSignMessageCallback\s*\(|\bsetWebAuthnKey\s*\('Repository: peanutprotocol/peanut-ui
Length of output: 157
🏁 Script executed:
#!/bin/bash
set -euo pipefail
printf "== Persisted WebAuthn key readers/writers ==\n"
rg -n -C3 --type ts '\bWEB_AUTHN_COOKIE_KEY\b|web-authn-key|saveToCookie\s*\(|getFromCookie\s*\('
printf "\n== Native callback attachment points ==\n"
rg -n -C3 --type ts '\bsignMessageCallback\b|createNativeSignMessageCallback|setWebAuthnKey\s*\('Repository: peanutprotocol/peanut-ui
Length of output: 13714
🏁 Script executed:
sed -n '160,185p' src/context/kernelClient.context.tsxRepository: peanutprotocol/peanut-ui
Length of output: 1413
🏁 Script executed:
rg -n 'isAndroidNative|signMessageCallback' src/ --type tsRepository: peanutprotocol/peanut-ui
Length of output: 763
Android native signing callbacks will not persist across app restarts.
The signMessageCallback assigned at lines 92 and 166 is a function and will not survive the JSON serialization that occurs when the key is saved to a cookie via saveToCookie(WEB_AUTHN_COOKIE_KEY, webAuthnKey, 90). When the app restarts or the page reloads, the stored key is restored from the cookie in kernelClient.context.tsx (line 173) without reattaching the native callback. Transaction signing will then fail on Android native, forcing a fallback to the broken web path.
The callback must be reattached during hydration for any code path that restores a persisted WebAuthn key on Android native.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/hooks/useZeroDev.ts` around lines 89 - 93, Persisted WebAuthn keys lose
their function-valued signMessageCallback (so Android native signing breaks); to
fix, when restoring a stored webAuthnKey on Android native reattach the native
signing callback by calling createNativeSignMessageCallback(rpId) and assigning
it to webAuthnKey.signMessageCallback before using or saving the key;
specifically, ensure the restore/hydration code path that reads the cookie (the
code in kernelClient.context.tsx that restores WEB_AUTHN_COOKIE_KEY) and any
place that loads webAuthnKey from storage re-applies
webAuthnKey.signMessageCallback = createNativeSignMessageCallback(rpId) (and
likewise do this wherever webAuthnKey is deserialized), so the native callback
persists across restarts.
| export function isCapacitor(): boolean { | ||
| return typeof window !== 'undefined' && !!(window as any).Capacitor |
There was a problem hiding this comment.
🧩 Analysis chain
🌐 Web query:
In Capacitor, can window.Capacitorbe defined when the app is running in a normal web browser, and isCapacitor.isNativePlatform()orCapacitor.getPlatform() !== "web" the recommended way to detect a native iOS/Android runtime?
💡 Result:
Yes, window.Capacitor is defined when running in a normal web browser, as Capacitor provides a JavaScript runtime that works across web, iOS, and Android platforms. The recommended way to detect a native iOS/Android runtime is Capacitor.isNativePlatform, which returns true for native platforms (ios/android) and false for web. Alternatively, Capacitor.getPlatform !== 'web' can be used, as getPlatform returns 'web', 'ios', or 'android'.
Citations:
- 1: https://capacitorjs.com/docs/core-apis/web
- 2: https://stackoverflow.com/questions/57745387/how-can-i-determine-if-the-current-platform-is-a-native-app-or-web-in-capacitor
- 3: https://stackoverflow.com/questions/65093514/ionic-5-capacitor-how-to-find-out-if-app-is-running-in-browser-or-compiled-as
- 4: https://next.capacitorjs.com/docs/v4/basics/utilities
🏁 Script executed:
# Check the actual implementation in src/utils/capacitor.ts
cat -n src/utils/capacitor.ts | head -20Repository: peanutprotocol/peanut-ui
Length of output: 919
🏁 Script executed:
# Search for usages of isCapacitor function
rg "isCapacitor" --no-heading -B 2 -A 2Repository: peanutprotocol/peanut-ui
Length of output: 3545
Use Capacitor.isNativePlatform() instead of checking window.Capacitor truthiness.
window.Capacitor is defined even in web builds when the Capacitor runtime is loaded. The current implementation will incorrectly trigger native-specific bypasses—including rpId override in passkey flows, skipped browser-level passkey checks, and API base URL overrides—on web builds. Replace with Capacitor.isNativePlatform(), which returns true only for native platforms (iOS/Android) and false for web, per the official Capacitor documentation.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/utils/capacitor.ts` around lines 6 - 7, The isCapacitor() function
currently checks window.Capacitor truthiness; change it to use
Capacitor.isNativePlatform() instead (i.e., return
Capacitor.isNativePlatform()). Ensure the Capacitor symbol is imported (for
example import { Capacitor } from '@capacitor/core' or the existing Capacitor
export used in the project) so isCapacitor() calls Capacitor.isNativePlatform()
directly and will only return true on native iOS/Android platforms.
| if (isCapacitor()) { | ||
| try { | ||
| // @ts-ignore -- @capacitor/browser may not be installed yet | ||
| const mod = await import('@capacitor/browser') | ||
| await mod.Browser.open({ url }) | ||
| } catch { | ||
| // fallback if plugin not installed | ||
| window.location.href = url |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
fd -HI '^(package\.json|pnpm-lock\.yaml|package-lock\.json|yarn\.lock)$' . -x sh -c '
printf "\n== %s ==\n" "$1"
rg -n -C1 "\"@capacitor/browser\"" "$1" || true
' sh {}Repository: peanutprotocol/peanut-ui
Length of output: 113
🏁 Script executed:
# Check if the file exists and view the exact lines mentioned
head -n 75 src/utils/capacitor.ts | tail -n 20Repository: peanutprotocol/peanut-ui
Length of output: 732
🏁 Script executed:
# View the full package.json to see all dependency sections
cat package.json | head -n 100Repository: peanutprotocol/peanut-ui
Length of output: 3757
🏁 Script executed:
# Search for any mention of capacitor anywhere in the repo
rg -i "capacitor" --type-list | head -5
rg -i "capacitor" -lRepository: peanutprotocol/peanut-ui
Length of output: 626
🏁 Script executed:
# Get the full package.json and check all dependency sections
cat package.json | tail -n +40Repository: peanutprotocol/peanut-ui
Length of output: 5269
🏁 Script executed:
# Also check if `@capacitor/browser` is mentioned anywhere else in the repo
rg "@capacitor" --no-headingRepository: peanutprotocol/peanut-ui
Length of output: 1867
Add @capacitor/browser to package.json as an optional dependency.
The dynamic import with @ts-ignore and catch block only provides runtime fallback. String-literal dynamic imports still resolve during bundling, so without @capacitor/browser declared in package.json and locked in pnpm-lock.yaml, the build can fail before the fallback executes. The package should be declared as an optionalDependency since it's conditionally used.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/utils/capacitor.ts` around lines 68 - 75, The bundler can fail because
the dynamic import of '@capacitor/browser' inside isCapacitor() (the try { const
mod = await import('@capacitor/browser'); await mod.Browser.open({ url }) }
catch { window.location.href = url }) still requires the package to be present
at bundle time; add "@capacitor/browser" to package.json under
"optionalDependencies" (not "dependencies") so it’s declared but non-fatal when
absent, then reinstall/update the lockfile (pnpm install / update
pnpm-lock.yaml) so the package is recorded and builds that reference the dynamic
import succeed while keeping the runtime fallback.
| // @ts-ignore -- @noble/curves/p256 requires pinning to v1.9.7 (v2 removed this export) | ||
| import { p256 } from '@noble/curves/p256' |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
fd -HI '^(package\.json|pnpm-lock\.yaml|package-lock\.json|yarn\.lock)$' . -x sh -c '
printf "\n== %s ==\n" "$1"
rg -n -C1 "\"@noble/curves\"" "$1" || true
' sh {}Repository: peanutprotocol/peanut-ui
Length of output: 113
Add @noble/curves as an explicit dependency pinned to 1.x in package.json.
The import at line 6 depends on an API that was removed in @noble/curves v2, but @noble/curves is not declared as a direct dependency in package.json. Without an explicit declaration and version pin, the import can break on dependency resolution changes. Add an entry like "@noble/curves": "^1.9.7" to package.json to ensure the correct API remains available and prevent runtime failures.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/utils/native-webauthn.ts` around lines 6 - 7, The code imports p256 from
'@noble/curves' in native-webauthn (import { p256 } from '@noble/curves/p256'),
but that API was removed in v2 and '@noble/curves' is not a direct dependency;
add an explicit pinned dependency to package.json (e.g. "@noble/curves":
"^1.9.7") so the v1 API remains available and dependency resolution won't
upgrade to v2.
| const clientDataJSON = atob(cred.response.clientDataJSON.replace(/-/g, '+').replace(/_/g, '/')) | ||
|
|
||
| // zerodev looks for '"type":"webauthn.get"' in clientDataJSON | ||
| const beforeType = BigInt(clientDataJSON.lastIndexOf('"type":"webauthn.get"')) |
There was a problem hiding this comment.
Decode clientDataJSON as Base64URL before searching it.
cred.response.clientDataJSON is another Base64URL field. atob() after only swapping -/_ skips padding normalization and can throw for valid payload lengths; if that happens, native signing fails before the ABI payload is built. Decode via base64URLToBytes() + TextDecoder, and guard the marker lookup before converting it to uint256.
Proposed fix
- const clientDataJSON = atob(cred.response.clientDataJSON.replace(/-/g, '+').replace(/_/g, '/'))
-
- // zerodev looks for '"type":"webauthn.get"' in clientDataJSON
- const beforeType = BigInt(clientDataJSON.lastIndexOf('"type":"webauthn.get"'))
+ const clientDataJSON = new TextDecoder().decode(
+ base64URLToBytes(cred.response.clientDataJSON)
+ )
+
+ // zerodev looks for '"type":"webauthn.get"' in clientDataJSON
+ const responseTypeLocation = clientDataJSON.lastIndexOf('"type":"webauthn.get"')
+ if (responseTypeLocation < 0) {
+ throw new Error('Missing webauthn.get marker in clientDataJSON')
+ }
+ const beforeType = BigInt(responseTypeLocation)🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/utils/native-webauthn.ts` around lines 184 - 187, The code currently
decodes cred.response.clientDataJSON with atob after only swapping -/_ which can
fail due to missing padding; replace that logic in native-webauthn by decoding
cred.response.clientDataJSON with base64URLToBytes(...) and then new
TextDecoder().decode(...) to get clientDataJSON, then search for the marker
before converting to BigInt: compute beforeType by using
clientDataJSON.lastIndexOf('"type":"webauthn.get"') and check it is >= 0 (or
throw/handle) before calling BigInt(beforeType) so you avoid invalid BigInt
conversions; update any uses of atob and the previous beforeType assignment
accordingly.
| // in capacitor, passkeys are handled natively — skip browser-level preflight checks | ||
| if (isCapacitor()) { | ||
| return { | ||
| isSupported: true, | ||
| warning: null, | ||
| diagnostics: { | ||
| hasPublicKeyCredential: true, | ||
| isHttps: true, | ||
| isAndroid: /android/i.test(navigator.userAgent), | ||
| rpId: 'peanut.me', | ||
| }, | ||
| } | ||
| } |
There was a problem hiding this comment.
Don't turn Capacitor into an unconditional green light.
This now reports isSupported: true plus successful diagnostics without checking whether the Android plugin is actually available or whether iOS associated domains are configured. src/components/Setup/Views/SetupPasskey.tsx only looks at isSupported/warning, so native misconfigurations stop surfacing here and the first failure moves to registration/signing.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/utils/passkeyPreflight.ts` around lines 33 - 45, The current
isCapacitor() branch unconditionally sets isSupported: true and green
diagnostics; instead, detect actual native support and surface a warning/false
when missing: call whatever native plugin presence check your app uses (e.g.,
the Capacitor passkey plugin accessor) and verify Android plugin availability
(rather than relying only on /android/i.test(navigator.userAgent)) and iOS
associated-domains configuration, then populate
diagnostics.hasPublicKeyCredential, isHttps, isAndroid, and rpId based on those
real checks and set isSupported: false and a descriptive warning when the native
plugin or associated domains are absent; keep the same diagnostics shape so
consumers like SetupPasskey.tsx can show the warning.
Tasks
Summary
isCapacitor,getApiBaseUrl,getPlatform) insrc/utils/capacitor.tssrc/utils/native-webauthn.tsuseZeroDev.ts)peanut.mein capacitor context for passkey portabilitycapacitor-webauthnplugin for signing via native credential managerusePasskeySupport.tsandpasskeyPreflight.tsTest plan
npx tsc --noEmitpasses (no new type errors introduced)