Skip to content

feat(security): biometric-guarded session token (Keychain/Keystore) - #2489

Closed
innolope-dev wants to merge 43 commits into
devfrom
feat/guarded-session-token
Closed

feat(security): biometric-guarded session token (Keychain/Keystore)#2489
innolope-dev wants to merge 43 commits into
devfrom
feat/guarded-session-token

Conversation

@innolope-dev

Copy link
Copy Markdown
Collaborator

Closes #2472 — the follow-up to #2461, which shipped the app lock as a privacy screen only.

What changes

Three session modes on native, detected once per launch (src/utils/auth-token.ts):

  • guarded — the JWT is stored via @capgo/capacitor-native-biometric (pinned 8.6.0, clears the 14-day dependency floor) with AccessControl.BIOMETRY_CURRENT_SET: Keychain SecAccessControl on iOS, a BiometricPrompt.CryptoObject-bound Keystore key on Android. The unlock ceremony IS the token read — one OS biometric prompt both proves presence and releases the credential. No separate WebAuthn assertion.
  • plain — byte-for-byte the previous behavior, kept for older binaries running OTA'd JS (plugin is feature-detected) and for devices without enrolled biometrics. After the legacy gate opens, the session migrates to guarded storage; the plain copy is deleted only after the next cold start's guarded read proves the round-trip.
  • none — signed out, nothing to protect.

Fail closed: the lock decision derives from a non-secret presence marker (guarded-token-present), never from the user query. Deleting the marker or any local pref yields a signed-out app — never an open session — because the JWT itself is unreadable without a biometric.

Session paused while locked: authReady() parks every API caller (a locked app never emits an unauthenticated request that would 401 → clearAuthToken()); the [USER] query is disabled (which also kills refetchOnWindowFocus on resume); the auto-refresh poller skips its tick; a sliding-refresh token landing after suspension is dropped. On unlock, react-query's stale refetch doubles as the post-unlock refresh.

Re-enrollment = session expired, never a stuck lock: BIOMETRY_CURRENT_SET invalidates the item when biometrics change (by design). Both platforms surface this as not-found → clean logout to /setup.

card.ts fix (from the #2463 re-review): services/card.ts read the jwt-token web cookie directly, which never worked on native — now routed through authReady() + getAuthHeaders().

Android write-prompt nuance

Keystore writes prompt outside a post-auth window (per-op auth keys). Handled with authValidityDuration: 60: the unlock read opens a 60s window that silently covers the re-mint /users/me ships right after unlock; writes outside the window are skipped (memory-only, server re-mints later). iOS Keychain writes never prompt. Consequence: on Android, login and one-time migration each show one extra 'Protect Credentials' prompt.

Deliberate trade-offs

  • Lock-screen Log out in guarded mode is local-only (skipBackendCall): there's no token in memory to authenticate the revocation POST. tokenVersion isn't bumped — acceptable since the attacker can't extract the guarded JWT.
  • Strict biometric-only (no device-passcode fallback): the plugin's gated path exposes only BIOMETRY_CURRENT_SET/BIOMETRY_ANY on iOS and AUTH_BIOMETRIC_STRONG keys on Android. After an OS biometric lockout the escape hatch is Log out → fresh login.
  • OTA rollback after migration cleanup looks signed-out (re-login) — accepted, worth a release-note line.

Testing

  • 168 suites / 2206 tests green, including new coverage: mode-detection truth table (incl. fail-closed uncertainty rule), authReady park/release, suspend-without-epoch-bump, locked setAuthToken no-op, silent-window write policy, migration round-trip lifecycle, plugin error-code mapping, lock registry, and a D7 regression test (gate locks without waiting for the user query — the permanent-white-screen trap).
  • Typecheck: same 228 pre-existing errors before/after (fresh-worktree asset-decl noise), zero introduced.
  • Needs a binary release (new plugin) + the on-device matrix from Native app lock: move the session token into biometric-guarded Keychain/Keystore (real control behind #2461) #2472 before store rollout: unlock→token released; cancel→stays locked; 6-min background; kill/relaunch; re-enroll→session-expired; old-build→new-build migration.

Plugin gate verified by code-read

iOS: SecAccessControlCreateWithFlags(kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly, .biometryCurrentSet) + SecItemCopyMatching with LAContext. Android: Keystore AES-GCM with setUserAuthenticationRequired(true), setUserAuthenticationValidityDurationSeconds(-1) (per-op), setInvalidatedByBiometricEnrollment(true), reads/writes through BiometricPrompt.CryptoObject ciphers. The ungated getCredentials()/verifyIdentity() paths are never used.

Resolves 8 conflicts. Where main reworded copy after it was extracted, the
catalog now follows main — the exit prompts had reversed meaning (the KYC
flow saves progress now, so 'you'll have to start over' was wrong in every
locale), and the withdraw title became 'You're withdrawing'.

Drops keys orphaned by main: the StartVerificationView interstitial and the
autoStart 'lose progress' variant are both gone.

Extracts copy main added into the localized surface: the /fix-card-signature
support page and the deposit-cancel warning. That warning's noun was
interpolated ('Cancel this {noun}?'), which can't be translated — es/pt need
gender agreement — so each kind carries its own sentence.
Four cooperating defects kept a dead session resurrecting itself and the
webview reloading in a flash loop that only 'clear all app data' escaped:

- CapacitorUpdater.set() reloads the webview IMMEDIATELY — the comment
  believed it deferred to next launch. Use next(), which actually defers.
- MainActivity served pre-rendered HTML from APK assets even while an OTA
  bundle was active, so any hard navigation (logout goes to /setup) loaded
  a stale export whose chunks no longer exist. Resolve HTML against the
  active server base path first, assets only when no bundle is live.
- logout cleared the JWT before cancelling queries, so an in-flight
  /users/me sliding refresh re-persisted the very token being wiped.
  Cancel + clear the query cache first.
- the sliding refresh now checks a clear-epoch counter and drops the
  re-minted token when the session was cleared mid-flight; the 401/404
  clearAuthToken is awaited so Preferences.remove lands before teardown.
The white-panel-above-the-inset layout is identical on both platforms, but
the white fill was gated to isIOSNative — Android 15 edge-to-edge painted
the nav-bar inset periwinkle under the white panel, reading as a stray
blue strip on the onboarding screen.
The animated-WebP mascots drop frames in the Android WebView — the same
symptom the GIF fallback fixed on legacy iOS — but the fallback was gated
on isLegacyWebKit, which can never match an Android UA.
The scale/opacity enter tween hitched on first-frame rasterization in the
Android WebView; transform-gpu + will-change-transform pre-promote the
layer. Also drops the dead max-h-[] class.
The redirect keyed on isloginClicked && user, and isloginClicked is set
before the ceremony — a stale user re-appearing mid-ceremony (the OS
passkey sheet blur/refocus fires refetchOnWindowFocus) pushed /home with
balance and activity rendered before any authentication. Gate the
redirect on loginResolved as well.
…itial

A valid, completed session (hasAppAccess) that cold-starts on /setup was
stopped at 'You're already signed in — Continue as <user>' whose CTA just
pushes /home. Route those straight home; keep the interstitial for the
half-finished-signup case it was written for.
Calls the new POST /users/logout (peanut-api-ts tokenVersion bump) with
the still-valid JWT before clearing local state, so logout now kills the
session on every device instead of only wiping this one's storage.
Best-effort: a dead backend falls through to local logout (and the
forced-logout path skips it entirely, as before).
Release builds signed the App Store configuration with App.entitlements,
which carries aps-environment=development. TestFlight/App Store binaries
therefore registered against the APNs sandbox while OneSignal sends via
production, so every push silently reached zero devices. Point the release
config at a new AppRelease.entitlements with aps-environment=production.

Android had the mirror-image hole: android-release.yml skipped
google-services.json when ANDROID_GOOGLE_SERVICES_JSON was unset and
build.gradle swallowed the missing file, so release AABs shipped with the
google-services plugin silently omitted and no FCM token registration.
Both now fail the build instead of shipping dead push, and the iOS
workflow verifies aps-environment in the provisioning profile and in the
exported IPA.
…gging

A failed OneSignal login() left the device subscription without the
external_id the backend targets, so pushes resolved to no recipients with
nothing in Sentry. Capture the failure, and only commit lastLinkedExternalId
on success so transient errors retry on the next sync rather than latching
the device into a permanently unlinked state.

Add opt-in verbose SDK logging (NEXT_PUBLIC_ONESIGNAL_DEBUG, baked in via
the pushDebug workflow input, or localStorage.__onesignal_debug) for
diagnosing release builds where NODE_ENV-gated tooling is eliminated.
Adapter imports conflicted: main gained notification-click handling after
the push-fix branch was cut. Kept both sides — click listeners and the
opt-in debug logging.
The earlier hard-fail was based on a wrong premise. OneSignal's Android SDK
never reads google-services.json: PushRegistratorFCM builds FirebaseOptions
in code from its own FCM_DEFAULT_* constants plus the sender ID served by the
OneSignal dashboard, and com.onesignal:notifications pulls firebase-messaging
in transitively. The google-services plugin only wires Firebase's automatic
initialization, which OneSignal bypasses.

The "Push Notifications won't work" comment that motivated the guard is
Capacitor's stock boilerplate for @capacitor/push-notifications, which does
need the file. This app uses OneSignal, which does not.

Keep validating the file when the secret is set; skip it when unset. Android
push depends on the OneSignal dashboard's FCM v1 config, not on this file.
OneSignal's Android SDK registers with FCM on its own — PushRegistratorFCM
builds FirebaseOptions in code from the sender ID served by the OneSignal
dashboard, and firebase-messaging arrives transitively via
com.onesignal:notifications. The google-services plugin and its JSON only
wire Firebase's automatic initialization, which OneSignal bypasses, so they
were dead weight.

Drop the com.google.gms:google-services classpath, the conditional plugin
apply, and the CI "Decode google-services.json" step. Android push now
depends solely on the OneSignal dashboard's FCM v1 config (a service-account
key, which is a different Firebase file than google-services.json).
isCoveredByDisableList only consulted ITEMS_TO_DISABLE, so routes handled via
P0_TRANSFORMS — whose replacements strip force-dynamic/generateMetadata — were
reported as uncovered server-only routes and failed the native build.
(mobile-ui)/claim/page.tsx is the case that broke it: it lives in
P0_TRANSFORMS, not ITEMS_TO_DISABLE, so the guard never saw its transform.
invite survived only because it also has a dir entry in ITEMS_TO_DISABLE.

Treat a route as covered if it's in either list. This regressed native
Android builds since ~v1.0.20; the bug is present on main too.
Google now rejects the Play Edit commit with "Changes are sent for review
automatically. The query parameter changesNotSentForReview must not be set."
once the app has a reviewed base. The flag was only needed for the very first
release; with a reviewed base the edit must go to review automatically. The
AAB itself uploaded fine — only the commit step failed on this flag.
…ugin

cap sync ios silently omits sources/SumsubCordovaIdensicMobileSdkPlugin
(exit 0, no folder) when node_modules isn't fully materialized, and the
committed CapApp-SPM/Package.swift then fails the archive a minute later
with a cryptic SwiftPM "folder doesn't exist" error.

- native-ios-postsync.js: hard-fail with an actionable message when the
  sources dir is missing but the plugin is still installed or still
  referenced by CapApp-SPM/Package.swift; only skip on genuine removal.
- ios-release.yml: verify the SumSub Cordova plugin materialized right
  after pnpm install, catching the partial-install root cause at the point
  where a job re-run recovers.
A bare `vX.Y.Z` tag push only set CURRENT_PROJECT_VERSION and left
MARKETING_VERSION at the project default 1.0, so tag releases shipped to
TestFlight as version 1.0 (flagged outdated). Fall back to the tag name
(v1.0.31 -> 1.0.31) when no explicit versionName input is given, matching
android-release.yml.
The suite was added on main while the component still used hardcoded
strings; the i18n branch converted it to useTranslations, so the two
only break when merged. Use the same IntlWrapper pattern as the rest
of the localized suites.
Three changes that together make PEANUT-UI-R5F diagnosable in the field:

- Split the single direct-fetch canary into three probes (unauthenticated
  GET, authorized GET, POST) so reachability, CORS preflight and the
  GET-vs-POST asymmetry seen on-device can be told apart. Probes run in
  parallel and report in a fixed order.
- Tag the binary version/build on every native Sentry event. With OTA the
  JS `release` and the installed binary diverge, which made R5F look like
  it came from a build it didn't.
- Actively unregister service workers inside the Capacitor WebView. Builds
  before 2026-04 registered the PWA SW there; the native bundle ships no
  sw.js, so those registrations can never self-update and sit frozen in
  front of all GET traffic.
… to production

Also updates the src/content submodule pointer to its latest commit.
…is blocked

The edge in front of api.peanut.me rejects Android WebView GETs at the
TLS-fingerprint level (PEANUT-UI-R5F): the block response has no CORS
headers, so fetch surfaces it as an instant opaque TypeError while POSTs
(passkey verify) and Sentry ingest sail through. CapacitorHttp.request()
uses the OS HTTP client — no browser fingerprint, no CORS — and is
registered natively regardless of the CapacitorHttp.enabled flag, so the
fallback is OTA-deliverable to existing 1.0.32 binaries.

fetchWithSentry retries API requests over the native transport when the
WebView fetch rejects; FormData bodies keep the WebView path.

Canary v3: per-probe messages (v2's identical messages were collapsed by
Sentry's dedupe integration — only the first probe ever arrived),
transport detection compares against the pre-wrap fetch (the auth-capture
wrapper made v2 report cap-http-proxy on proxy-off binaries), plus
no-cors and native-transport probes to separate edge blocks from dead
networks.
Demo mode is the app-store review sandbox — synthetic data, no real
backend — so the beta-feedback, connectivity, and maintenance banners
don't apply there.
… fallback (OTA download broken on affected devices)
Brings 53 commits from main into the release branch ahead of the next
mobile build: push-tap routing, OneSignal failure reporting, biometric
app lock, fresh-passkey step-up, report-only CSP, Manteca per-rail
wallet hotfix, Rhino route minimums and the Sentry noise fixes.

Nine conflicts, all main-adds-behaviour vs mobile-release-adds-i18n.
Resolved to keep BOTH in every case:

- ClientProviders: main's AppLockGate now wraps children INSIDE
  mobile-release's AppIntlProvider > ContextProvider nesting. Getting
  this backwards 500s the whole app and unit tests don't catch it.
- authContext: took main's clearStepUpToken(); dropped its duplicate
  query cancel/clear, which already runs earlier here — deliberately
  BEFORE the token wipe, or an in-flight /users/me re-persists a
  sliding-refresh token and logout never sticks on Android.
- PhysicalCardScreen: main's null-position fix expressed through the
  translation layer. Needed a new onListBodyNoPosition key in all three
  locales rather than interpolating '#null'.
- withdraw: kept main's USD-pinned validation (no token-price scaling)
  with mobile-release's translated error copy; dropped the now-dead
  tokenSelectorContext and INSUFFICIENT_BALANCE_MESSAGE imports.
- ApplicationStatusScreen: main's proof-of-address props with
  mobile-release's COPY_KEYS lookup.
- notifications page: kept the localized date-group header, dropped
  main's hardcoded-English formatGroupHeaderDate.
- src/content: took main's production-published pointer (ca7f9ca). The
  only legal-copy delta is a support@ contact address; the Rain-superset
  prohibited-activities expansion is present in both.

Two suites from main rendered components that now call useTranslations;
wrapped both in NextIntlClientProvider, same pattern as the existing
qr-pay and SumsubKycWrapper tests.

Full suite green: 162 suites, 2133 passed.
@coderabbitai

coderabbitai Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: daab744c-02a6-49db-8588-2f5d98a2a038

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/guarded-session-token

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

auth-token.ts is reachable from Server Component pages (charges.ts →
[...recipient]/page.tsx), so app-lock-state.ts must stay hook-free —
useSyncExternalStore in that module failed the production build on
Vercel. Move the useAppLocked hook into its own client file.
… any

Extends the existing global Window.Capacitor declaration — the only
eslint error this branch added on top of the known-red baseline.
Match main's #2493: drop the 'locked / could not confirm' framing for
a plain log-in ask ('Welcome back!' + 'Please log in to access the
app.', button 'Log in').
@innolope-dev

Copy link
Copy Markdown
Collaborator Author

Pushed dab8c99 — lock-screen copy reworded to match #2493 (friendlier log-in framing instead of 'locked / could not confirm'), updated in all three locales. Tests green (messages + app-lock-gate).

@kushagrasarathe kushagrasarathe left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Reviewed adversarially (external contributor, security control to a release branch) — requesting changes on one code item; the design itself is sound.

This is the real fix for the cosmetic app-lock (#2461 / issue #2472), and it genuinely closes those gaps: the JWT is now stored via NativeBiometric.setCredentials({ accessControl: BIOMETRY_CURRENT_SET }) (Keychain/Keystore, biometric-gated) rather than plain Preferences; the gate fails closed (auth state derives from the presence of the guarded token, so deleting a pref signs the user out, never opens the app); and the session is paused while locked (no /users/me polling with a live token). @capgo/capacitor-native-biometric@8.6.0 clears the 14-day supply-chain floor and its integrity matches npm. No backdoor.

Requested change (code):

  • onramp-quote.ts:39 skips the gate — add await authReady() before it fires so the one un-gated caller can't run mid-lock.

Merge-gate conditions (not code, but required before this rolls to the store):

  1. Run the on-device iOS + Android matrix — CI cannot prove the native biometric binding actually holds; this must be verified on hardware.
  2. Explicit sign-off on the two accepted residuals: (a) the migration-window plaintext copy kept until the first successful guarded unlock, and (b) the legacy plain-mode fail-open for older binaries / no enrolled biometric (those sessions have no guarded token to protect, so it's by-design — but call it out and accept it consciously).

Once authReady() lands and the device matrix + residual sign-off are done, this is good to ship.

Opening the app and viewing the balance is not treated as a critical
vulnerability — matching the web app, where the same read-only view is
ungated. Money movement stays passkey-gated at the transaction layer,
independently of this flag.

- add OPEN_GATED flag (NEXT_PUBLIC_APP_OPEN_GATED, default false)
- AppLockGate renders children straight through when the flag is off
- guarded-storage use in auth-token gated via guardedModeEnabled(), so the
  JWT stays in plain Preferences and the session remains readable without a
  biometric; the guarded infrastructure stays intact for when it is enabled
- onramp-quote: await authReady() before building auth headers so the one
  un-gated caller parks instead of firing unauthenticated mid-lock
- app-lock copy: 'Log in' -> 'Unlock' (en/es-419/pt-BR) — the ceremony is a
  biometric unlock of an existing session, not a login

Adds tests covering guarded mode staying dormant when the flag is off.
@innolope-dev

Copy link
Copy Markdown
Collaborator Author

Pushed f38dd83 addressing the review:

Product change — app-open is no longer gated by default. We decided that opening the app and seeing the balance isn't a critical vulnerability (the web app doesn't gate it either); money movement stays passkey-gated at the transaction layer. Rather than drop the work, it's behind a new OPEN_GATED flag (NEXT_PUBLIC_APP_OPEN_GATED, default false):

  • AppLockGate renders straight through when off — no lock screen, no biometric to open.
  • Guarded-storage use in auth-token.ts is gated via guardedModeEnabled(), so the JWT stays in plain Preferences and the session is readable without an unlock. The entire guarded infrastructure (secure-token-store, guarded mode, the unlock ceremony) stays intact and re-activates by flipping the flag to true.

Requested code change: onramp-quote.ts now await authReady() before building headers, so the one un-gated caller parks instead of firing unauthenticated mid-lock.

Copy nit: appLock button 'Log in' → 'Unlock' (en/es-419/pt-BR) — it's a biometric unlock of an existing session.

Added tests covering guarded mode staying dormant when the flag is off; auth-token/secure-token-store suites green (69 passing), typecheck clean.

The device-matrix + residual sign-off gates only apply when OPEN_GATED is enabled — with it off there's no guarded token to protect on-device.

@kushagrasarathe

Copy link
Copy Markdown
Contributor

Re-reviewed f38dd83f1 — the design changes are good, but CI is red. One test fix needed before this can merge.

What's addressed:

  • authReady() is now awaited in onramp-quote.ts — the one un-gated caller is fixed. ✅
  • The OPEN_GATED feature-flag approach (default off) is a genuinely better rollout than what I asked for: it ships the biometric-guarded infrastructure dormant, so a default build carries zero on-device biometric-binding risk, and the two residuals I flagged (migration-window plaintext, legacy fail-open) are moot while the flag is off — guardedModeEnabled() short-circuits every guarded path. The guardedModeEnabled() helper centralizes the gate cleanly and all call sites are updated consistently. My earlier conditions are effectively resolved by this design: on-device iOS/Android testing becomes the gate for flipping the flag on, not for merging.

Blocker — CI is red (unit + ci-success failing):
The new gate if (!isCapacitor() || !OPEN_GATED) return in AppLockGate broke two pre-existing tests in src/components/Global/AppLock/__tests__/app-lock-gate.test.tsx:

  • guarded mode: locks and suspends the session without waiting for the user query (D7)
  • guarded mode: stays locked when the prompt is cancelled

OPEN_GATED defaults to false in the test env (no NEXT_PUBLIC_APP_OPEN_GATED set), so the gate now early-returns and never locks — the guarded-mode assertions fail. This file wasn't touched in the commit, so it's stale relative to the new flag.
Fix: set process.env.NEXT_PUBLIC_APP_OPEN_GATED = 'true' at the top of app-lock-gate.test.tsx (same pattern already added to auth-token.test.ts) so the guarded-mode tests actually engage the gate. Then re-run unit.

Two things to flag for the merge decision (not blockers, but be aware):

  1. Merging this ships the app-open lock OFF by default — the JWT stays in plain Preferences, same as current production (no regression, but issue Native app lock: move the session token into biometric-guarded Keychain/Keystore (real control behind #2461) #2472's protection is dormant until NEXT_PUBLIC_APP_OPEN_GATED=true is set, which should follow the on-device test pass).
  2. This commit reverts the lock-screen copy to "Unlock" (from "Log in"), which supersedes fix(app-lock): friendlier lock-screen copy #2493fix(app-lock): friendlier lock-screen copy #2493 can be closed once this lands.

Once the test is green, I'm good to approve.

…-through

The app-open lock is now dormant behind OPEN_GATED (default off), so the
guarded-mode lock tests failed — the effect returns early and never locks.
Mock the flag on for those cases and add a case asserting a guarded session
opens straight through when the flag is off.
Mirror production, where OPEN_GATED is off when NEXT_PUBLIC_APP_OPEN_GATED is
unset. The gate-exercising cases enable it explicitly.
@innolope-dev

Copy link
Copy Markdown
Collaborator Author

Tests are green as of 3837f2a + 27bf61eapp-lock-gate.test.tsx now opts into OPEN_GATED per lock case (unit and ci-success both pass on the latest push; the only red check is the Vercel preview deploy, unrelated to the gate change). Re-requesting review per your "once the test is green" condition.

Note: this PR is not included in the v1.0.41 build going out now — it stays queued for the next release after your re-review, shipping dormant (OPEN_GATED off) as discussed.

@innolope-dev
innolope-dev changed the base branch from mobile-release to dev July 29, 2026 11:44
innolope-dev added a commit that referenced this pull request Jul 29, 2026
Biometric-guarded session token (Keychain/Keystore via NativeBiometric),
dormant behind OPEN_GATED (default off). Supersedes the release-side
stopgap that backported the flag onto the cosmetic app lock (6196d13):
AppLock and app-lock.consts.ts taken from the branch; card.ts keeps the
release-side apiFetch refactor, which awaits authReady() internally; lock
copy uses the branch's 'Unlock' wording to match the biometric action.
@innolope-dev

Copy link
Copy Markdown
Collaborator Author

Superseded by #2568 (same content, transplanted onto dev).

What happened here:

  • The feature is merged into mobile-release directly (87f14c4) per our no-PR release-branch flow — conflicts with the release-side OPEN_GATED stopgap (6196d13) resolved in favor of this branch; card.ts keeps the release-side apiFetch refactor (it awaits authReady() internally, so the mid-lock gate holds).
  • This PR was retargeted at dev, but the branch is based on the mobile-release lineage and repo rules forbid the force-push a clean transplant needs — merging dev in here instead would pull release-branch ancestry into dev on merge. Hence feat(security): biometric-guarded session token (Keychain/Keystore) #2568 with the same seven commits rebased onto dev.

@kushagrasarathe your review carries over — the authReady() gate and the green gate tests are all in #2568. The two merge-gate conditions (on-device iOS+Android matrix, residual sign-off) remain tracked for flipping OPEN_GATED on, since the feature ships dormant. Closing this one.

innolope-dev added a commit that referenced this pull request Jul 29, 2026
Bundles the guarded-session-token merge (#2489, biometric session
token behind OPEN_GATED) and the Android < 15 phantom safe-area band
fix (5e33446) into the next Android release.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants