From 9735d65c9962217c126e95110de9d61d47861963 Mon Sep 17 00:00:00 2001 From: chrispader Date: Mon, 27 Jul 2026 10:22:09 +0200 Subject: [PATCH 01/14] Revert "Merge pull request #97000 from Expensify/revert-95518-@chrispader/enable-nitro-fetch-v3" This reverts commit 055f6b063b58070c401ad91f2f82fe08ca158439, reversing changes made to 860004694aed50d4baaf1dd5fb7ea00f6d927845. --- .../com/expensify/chat/CertificatePinning.kt | 8 +- .../com/expensify/chat/MainApplication.kt | 7 + config/certificatePinning/pins.json | 2 +- index.js | 1 + ios/Podfile.lock | 40 ++- jest/setup.ts | 9 + .../android/ExpensifyNitroUtilsOnLoad.cpp | 8 +- .../generated/android/c++/JContact.hpp | 2 +- .../generated/android/c++/JContactFields.hpp | 2 +- .../c++/JHybridAppStartTimeModuleSpec.hpp | 4 +- .../android/c++/JHybridContactsModuleSpec.hpp | 4 +- .../generated/android/c++/JStringHolder.hpp | 2 +- .../kotlin/com/margelo/nitro/utils/Contact.kt | 21 ++ .../com/margelo/nitro/utils/StringHolder.kt | 13 + .../ios/ExpensifyNitroUtils+autolinking.rb | 2 + package-lock.json | 56 ++- package.json | 5 +- patches/details.md | 17 + patches/react-native-nitro-fetch+1.5.0.patch | 332 ++++++++++++++++++ scripts/generateCertificatePins.sh | 1 + server/stubs/react-native-nitro-fetch.ts | 6 +- src/CONST/index.ts | 3 + src/libs/API/types.ts | 4 +- src/libs/HttpUtils.ts | 14 +- src/libs/Network/enhanceParameters.ts | 41 ++- .../Prefetch/PrefetchQueries/index.native.ts | 8 + src/libs/Prefetch/PrefetchQueries/index.ts | 8 + .../Prefetch/clearPrefetchOnAppStart/index.ts | 18 + .../clearPrefetchOnAppStart/index.web.ts | 5 + .../Prefetch/clearPrefetchOnAppStart/types.ts | 3 + .../Prefetch/preparePrefetchRequest/index.ts | 22 ++ .../preparePrefetchRequest/index.web.ts | 7 + .../Prefetch/preparePrefetchRequest/types.ts | 8 + .../registerPrefetchOnAppStart/index.ts | 80 +++++ .../registerPrefetchOnAppStart/index.web.ts | 7 + .../registerPrefetchOnAppStart/types.ts | 10 + src/libs/Reauthentication.ts | 15 +- src/libs/Request.ts | 32 +- src/libs/SessionUtils.ts | 20 +- src/libs/actions/Session/index.ts | 13 +- .../Session/updateSessionAuthTokens.ts | 6 + .../actions/clearOnyxAndSeedFullReconnect.ts | 5 + .../telemetry/trackAuthenticationError.ts | 4 +- src/polyfills/NitroFetch.ts | 10 + src/polyfills/NitroFetch.web.ts | 1 + src/types/onyx/Session.ts | 2 +- tests/actions/SessionTest.ts | 41 ++- tests/unit/APITest.ts | 5 +- tests/unit/AuthPrefetchTest.ts | 61 ++++ tests/unit/NetworkTest.tsx | 7 +- tests/unit/SessionUtilsTest.ts | 26 +- 51 files changed, 936 insertions(+), 92 deletions(-) create mode 100644 patches/details.md create mode 100644 patches/react-native-nitro-fetch+1.5.0.patch create mode 100644 src/libs/Prefetch/PrefetchQueries/index.native.ts create mode 100644 src/libs/Prefetch/PrefetchQueries/index.ts create mode 100644 src/libs/Prefetch/clearPrefetchOnAppStart/index.ts create mode 100644 src/libs/Prefetch/clearPrefetchOnAppStart/index.web.ts create mode 100644 src/libs/Prefetch/clearPrefetchOnAppStart/types.ts create mode 100644 src/libs/Prefetch/preparePrefetchRequest/index.ts create mode 100644 src/libs/Prefetch/preparePrefetchRequest/index.web.ts create mode 100644 src/libs/Prefetch/preparePrefetchRequest/types.ts create mode 100644 src/libs/Prefetch/registerPrefetchOnAppStart/index.ts create mode 100644 src/libs/Prefetch/registerPrefetchOnAppStart/index.web.ts create mode 100644 src/libs/Prefetch/registerPrefetchOnAppStart/types.ts create mode 100644 src/polyfills/NitroFetch.ts create mode 100644 src/polyfills/NitroFetch.web.ts create mode 100644 tests/unit/AuthPrefetchTest.ts diff --git a/android/app/src/main/java/com/expensify/chat/CertificatePinning.kt b/android/app/src/main/java/com/expensify/chat/CertificatePinning.kt index 70c463d5ce5f..0cd1f23b38d5 100644 --- a/android/app/src/main/java/com/expensify/chat/CertificatePinning.kt +++ b/android/app/src/main/java/com/expensify/chat/CertificatePinning.kt @@ -12,9 +12,11 @@ import javax.net.ssl.SSLPeerUnverifiedException /** * Certificate pinning for React Native's shared OkHttp client (Iteration 1 - NewDot). * - * On Android, `react-native-blob-util` (authenticated attachment/receipt downloads) and React Native - * networking (including `fetch()`) route through `OkHttpClientProvider.getOkHttpClient()`. Installing - * an [OkHttpClientProvider] factory with an OkHttp [CertificatePinner] here pins that traffic. + * On Android, `react-native-blob-util` (authenticated attachment/receipt downloads), Fresco, and + * other React Native networking consumers route through `OkHttpClientProvider.getOkHttpClient()`. + * Installing an [OkHttpClientProvider] factory with an OkHttp [CertificatePinner] here pins that + * traffic. JavaScript `fetch()` uses NitroFetch/Cronet and is pinned separately by the + * `react-native-nitro-fetch` patch. * * Additional networking channels are also monitored: * - **HttpURLConnection**: A wrapping [javax.net.ssl.HostnameVerifier] validates pins after the diff --git a/android/app/src/main/java/com/expensify/chat/MainApplication.kt b/android/app/src/main/java/com/expensify/chat/MainApplication.kt index 987a3d9b7d7c..89c47fdfa86d 100644 --- a/android/app/src/main/java/com/expensify/chat/MainApplication.kt +++ b/android/app/src/main/java/com/expensify/chat/MainApplication.kt @@ -14,6 +14,7 @@ import androidx.multidex.MultiDexApplication import com.expensify.chat.bootsplash.BootSplashPackage import com.expensify.chat.navbar.NavBarManagerPackage import com.expensify.chat.shortcutManagerModule.ShortcutManagerPackage +import com.margelo.nitro.nitrofetch.AutoPrefetcher import com.facebook.react.PackageList import com.facebook.react.ReactApplication import com.facebook.react.ReactHost @@ -79,6 +80,12 @@ class MainApplication : MultiDexApplication(), ReactApplication { // Install certificate pinning for React Native's shared OkHttp client (covers fetch(), // react-native-blob-util, etc.). Must run before any networking starts. CertificatePinning.install() + // This is the entrypoint for prefetching with `react-native-nitro-fetch`. + try { + AutoPrefetcher.prefetchOnStart(this) + } catch (_: Throwable) { + System.err.println("Error initializing Nitro `AutoPrefetcher`") + } loadReactNative(this) diff --git a/config/certificatePinning/pins.json b/config/certificatePinning/pins.json index b456bb9870b9..dce0f95bfcdb 100644 --- a/config/certificatePinning/pins.json +++ b/config/certificatePinning/pins.json @@ -1,5 +1,5 @@ { - "_comment": "Canonical source of truth for SSL certificate pins (Iteration 1 - NewDot). Regenerate with scripts/generateCertificatePins.sh. Each domain pins the leaf SPKI hash (primary) and its issuing intermediate CA SPKI hash (durable backup that survives leaf rotation). Hashes are base64-encoded SHA-256 of the Subject Public Key Info. Native code (Android network_security_config_enforce.xml, Android OkHttpClientFactory, iOS TrustKit) MUST be kept in sync with this file - the generation script prints the values for each target.", + "_comment": "Canonical source of truth for SSL certificate pins (Iteration 1 - NewDot). Regenerate with scripts/generateCertificatePins.sh. Each domain pins the leaf SPKI hash (primary) and its issuing intermediate CA SPKI hash (durable backup that survives leaf rotation). Hashes are base64-encoded SHA-256 of the Subject Public Key Info. Native code (Android network_security_config_enforce.xml, Android OkHttpClientFactory, Android NitroFetch Cronet, iOS TrustKit) MUST be kept in sync with this file - the generation script prints the values for each target.", "_lastGenerated": "2026-06-03", "enforcePinning": false, "domains": { diff --git a/index.js b/index.js index 978a5ffc7cfd..cf005180666d 100644 --- a/index.js +++ b/index.js @@ -2,6 +2,7 @@ * @format */ // import of polyfills should always be first +import './src/polyfills/NitroFetch'; import './src/polyfills/PromiseWithResolvers'; import './src/polyfills/requestIdleCallback'; import {AppRegistry} from 'react-native'; diff --git a/ios/Podfile.lock b/ios/Podfile.lock index cdbdf8ce37e3..80a1da3d9244 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -410,7 +410,37 @@ PODS: - nanopb/encode (= 3.30910.0) - nanopb/decode (3.30910.0) - nanopb/encode (3.30910.0) - - NitroModules (0.35.0): + - NitroFetch (1.5.0): + - boost + - DoubleConversion + - fast_float + - fmt + - glog + - hermes-engine + - NitroModules + - RCT-Folly + - RCT-Folly/Fabric + - RCTRequired + - RCTTypeSafety + - React-callinvoker + - React-Core + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - SocketRocket + - Yoga + - NitroModules (0.35.9): - boost - DoubleConversion - fast_float @@ -4336,6 +4366,7 @@ DEPENDENCIES: - GzipSwift - hermes-engine (from `../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec`) - lottie-react-native (from `../node_modules/lottie-react-native`) + - NitroFetch (from `../node_modules/react-native-nitro-fetch`) - NitroModules (from `../node_modules/react-native-nitro-modules`) - "onfido-react-native-sdk (from `../node_modules/@onfido/react-native-sdk`)" - "pusher-websocket-react-native (from `../node_modules/@pusher/pusher-websocket-react-native`)" @@ -4566,6 +4597,8 @@ EXTERNAL SOURCES: :tag: hermes-v250829098.0.10 lottie-react-native: :path: "../node_modules/lottie-react-native" + NitroFetch: + :path: "../node_modules/react-native-nitro-fetch" NitroModules: :path: "../node_modules/react-native-nitro-modules" onfido-react-native-sdk: @@ -4817,7 +4850,7 @@ SPEC CHECKSUMS: DoubleConversion: cb417026b2400c8f53ae97020b2be961b59470cb EXConstants: 1c400bb9969f4c9e5ab324553138b74ae47b9efe expensify-react-native-background-task: 03c640e1f5649692d058cba48c0a138f024a6dd3 - ExpensifyNitroUtils: 86109fe1ab88351ed63ffe11b760d537c70019d7 + ExpensifyNitroUtils: b7225649e39a31eab926dc8551c9c64e15b98d47 Expo: 844d649bc6228d8fa118f29984565b58745bc1d8 ExpoAsset: c2e7b5ba1fe75be683282d6264e38fd7cd8defbb ExpoAudio: 7774082d316ceecc2b26efeb10480bea2fa4d67e @@ -4864,7 +4897,8 @@ SPEC CHECKSUMS: MapboxCoreMaps: de1a4461d0ab5d65d907f06e8d82fdbe06bb3213 MapboxMaps: 22340ae0e0d47b2e4761019b1d6c569ab04e088d nanopb: fad817b59e0457d11a5dfbde799381cd727c1275 - NitroModules: f8c2cc3025e4550aee15ff77c525622bf98e774a + NitroFetch: e3ed4327927d6462de3b7cb3afff273fc4907867 + NitroModules: d2d7b8a417d5f498ef5affa78d82113ad8c35c59 NWWebSocket: b4741420f1976e1dff4da3edad00c401e4f1d769 Onfido: 65454f91d10758193c857fd149417f6efbea84c5 onfido-react-native-sdk: bb8cfd9198e2e97978461d969497d18b37e45ca7 diff --git a/jest/setup.ts b/jest/setup.ts index c4d09ed07c61..72256c7efa57 100644 --- a/jest/setup.ts +++ b/jest/setup.ts @@ -313,6 +313,15 @@ jest.mock('react-native-nitro-sqlite', () => ({ open: jest.fn(), })); +jest.mock('react-native-nitro-fetch', () => ({ + __esModule: true, + fetch: (...args: Parameters) => globalThis.fetch(...args), + prefetchOnAppStart: jest.fn(() => Promise.resolve()), + registerTokenRefresh: jest.fn(), + clearTokenRefresh: jest.fn(), + removeFromAutoPrefetch: jest.fn(() => Promise.resolve()), +})); + jest.mock('@shopify/react-native-skia', () => ({ useFont: jest.fn(() => null), matchFont: jest.fn(() => null), diff --git a/modules/ExpensifyNitroUtils/nitrogen/generated/android/ExpensifyNitroUtilsOnLoad.cpp b/modules/ExpensifyNitroUtils/nitrogen/generated/android/ExpensifyNitroUtilsOnLoad.cpp index f328c82635bd..ba0c3e861922 100644 --- a/modules/ExpensifyNitroUtils/nitrogen/generated/android/ExpensifyNitroUtilsOnLoad.cpp +++ b/modules/ExpensifyNitroUtils/nitrogen/generated/android/ExpensifyNitroUtilsOnLoad.cpp @@ -28,17 +28,17 @@ int initialize(JavaVM* vm) { } struct JHybridContactsModuleSpecImpl: public jni::JavaClass { - static auto constexpr kJavaDescriptor = "Lcom/margelo/nitro/utils/HybridContactsModule;"; + static constexpr auto kJavaDescriptor = "Lcom/margelo/nitro/utils/HybridContactsModule;"; static std::shared_ptr create() { - static auto constructorFn = javaClassStatic()->getConstructor(); + static const auto constructorFn = javaClassStatic()->getConstructor(); jni::local_ref javaPart = javaClassStatic()->newObject(constructorFn); return javaPart->getJHybridContactsModuleSpec(); } }; struct JHybridAppStartTimeModuleSpecImpl: public jni::JavaClass { - static auto constexpr kJavaDescriptor = "Lcom/margelo/nitro/utils/HybridAppStartTimeModule;"; + static constexpr auto kJavaDescriptor = "Lcom/margelo/nitro/utils/HybridAppStartTimeModule;"; static std::shared_ptr create() { - static auto constructorFn = javaClassStatic()->getConstructor(); + static const auto constructorFn = javaClassStatic()->getConstructor(); jni::local_ref javaPart = javaClassStatic()->newObject(constructorFn); return javaPart->getJHybridAppStartTimeModuleSpec(); } diff --git a/modules/ExpensifyNitroUtils/nitrogen/generated/android/c++/JContact.hpp b/modules/ExpensifyNitroUtils/nitrogen/generated/android/c++/JContact.hpp index 2dccb42fa544..bcaa90160b67 100644 --- a/modules/ExpensifyNitroUtils/nitrogen/generated/android/c++/JContact.hpp +++ b/modules/ExpensifyNitroUtils/nitrogen/generated/android/c++/JContact.hpp @@ -25,7 +25,7 @@ namespace margelo::nitro::utils { */ struct JContact final: public jni::JavaClass { public: - static auto constexpr kJavaDescriptor = "Lcom/margelo/nitro/utils/Contact;"; + static constexpr auto kJavaDescriptor = "Lcom/margelo/nitro/utils/Contact;"; public: /** diff --git a/modules/ExpensifyNitroUtils/nitrogen/generated/android/c++/JContactFields.hpp b/modules/ExpensifyNitroUtils/nitrogen/generated/android/c++/JContactFields.hpp index 1ed3380932ce..8252d90ad4ef 100644 --- a/modules/ExpensifyNitroUtils/nitrogen/generated/android/c++/JContactFields.hpp +++ b/modules/ExpensifyNitroUtils/nitrogen/generated/android/c++/JContactFields.hpp @@ -19,7 +19,7 @@ namespace margelo::nitro::utils { */ struct JContactFields final: public jni::JavaClass { public: - static auto constexpr kJavaDescriptor = "Lcom/margelo/nitro/utils/ContactFields;"; + static constexpr auto kJavaDescriptor = "Lcom/margelo/nitro/utils/ContactFields;"; public: /** diff --git a/modules/ExpensifyNitroUtils/nitrogen/generated/android/c++/JHybridAppStartTimeModuleSpec.hpp b/modules/ExpensifyNitroUtils/nitrogen/generated/android/c++/JHybridAppStartTimeModuleSpec.hpp index 91fc411edbd0..3a332f45b6f8 100644 --- a/modules/ExpensifyNitroUtils/nitrogen/generated/android/c++/JHybridAppStartTimeModuleSpec.hpp +++ b/modules/ExpensifyNitroUtils/nitrogen/generated/android/c++/JHybridAppStartTimeModuleSpec.hpp @@ -21,11 +21,11 @@ namespace margelo::nitro::utils { class JHybridAppStartTimeModuleSpec: public virtual HybridAppStartTimeModuleSpec, public virtual JHybridObject { public: struct JavaPart: public jni::JavaClass { - static auto constexpr kJavaDescriptor = "Lcom/margelo/nitro/utils/HybridAppStartTimeModuleSpec;"; + static constexpr auto kJavaDescriptor = "Lcom/margelo/nitro/utils/HybridAppStartTimeModuleSpec;"; std::shared_ptr getJHybridAppStartTimeModuleSpec(); }; struct CxxPart: public jni::HybridClass { - static auto constexpr kJavaDescriptor = "Lcom/margelo/nitro/utils/HybridAppStartTimeModuleSpec$CxxPart;"; + static constexpr auto kJavaDescriptor = "Lcom/margelo/nitro/utils/HybridAppStartTimeModuleSpec$CxxPart;"; static jni::local_ref initHybrid(jni::alias_ref jThis); static void registerNatives(); using HybridBase::HybridBase; diff --git a/modules/ExpensifyNitroUtils/nitrogen/generated/android/c++/JHybridContactsModuleSpec.hpp b/modules/ExpensifyNitroUtils/nitrogen/generated/android/c++/JHybridContactsModuleSpec.hpp index b140e3c9f376..36a9aee8047d 100644 --- a/modules/ExpensifyNitroUtils/nitrogen/generated/android/c++/JHybridContactsModuleSpec.hpp +++ b/modules/ExpensifyNitroUtils/nitrogen/generated/android/c++/JHybridContactsModuleSpec.hpp @@ -21,11 +21,11 @@ namespace margelo::nitro::utils { class JHybridContactsModuleSpec: public virtual HybridContactsModuleSpec, public virtual JHybridObject { public: struct JavaPart: public jni::JavaClass { - static auto constexpr kJavaDescriptor = "Lcom/margelo/nitro/utils/HybridContactsModuleSpec;"; + static constexpr auto kJavaDescriptor = "Lcom/margelo/nitro/utils/HybridContactsModuleSpec;"; std::shared_ptr getJHybridContactsModuleSpec(); }; struct CxxPart: public jni::HybridClass { - static auto constexpr kJavaDescriptor = "Lcom/margelo/nitro/utils/HybridContactsModuleSpec$CxxPart;"; + static constexpr auto kJavaDescriptor = "Lcom/margelo/nitro/utils/HybridContactsModuleSpec$CxxPart;"; static jni::local_ref initHybrid(jni::alias_ref jThis); static void registerNatives(); using HybridBase::HybridBase; diff --git a/modules/ExpensifyNitroUtils/nitrogen/generated/android/c++/JStringHolder.hpp b/modules/ExpensifyNitroUtils/nitrogen/generated/android/c++/JStringHolder.hpp index 11b563d2b695..a26b81bdbe47 100644 --- a/modules/ExpensifyNitroUtils/nitrogen/generated/android/c++/JStringHolder.hpp +++ b/modules/ExpensifyNitroUtils/nitrogen/generated/android/c++/JStringHolder.hpp @@ -21,7 +21,7 @@ namespace margelo::nitro::utils { */ struct JStringHolder final: public jni::JavaClass { public: - static auto constexpr kJavaDescriptor = "Lcom/margelo/nitro/utils/StringHolder;"; + static constexpr auto kJavaDescriptor = "Lcom/margelo/nitro/utils/StringHolder;"; public: /** diff --git a/modules/ExpensifyNitroUtils/nitrogen/generated/android/kotlin/com/margelo/nitro/utils/Contact.kt b/modules/ExpensifyNitroUtils/nitrogen/generated/android/kotlin/com/margelo/nitro/utils/Contact.kt index ca037de30643..816608d30d71 100644 --- a/modules/ExpensifyNitroUtils/nitrogen/generated/android/kotlin/com/margelo/nitro/utils/Contact.kt +++ b/modules/ExpensifyNitroUtils/nitrogen/generated/android/kotlin/com/margelo/nitro/utils/Contact.kt @@ -9,6 +9,7 @@ package com.margelo.nitro.utils import androidx.annotation.Keep import com.facebook.proguard.annotations.DoNotStrip +import java.util.Objects /** @@ -35,6 +36,26 @@ data class Contact( ) { /* primary constructor */ + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is Contact) return false + return Objects.deepEquals(this.firstName, other.firstName) + && Objects.deepEquals(this.lastName, other.lastName) + && Objects.deepEquals(this.phoneNumbers, other.phoneNumbers) + && Objects.deepEquals(this.emailAddresses, other.emailAddresses) + && Objects.deepEquals(this.imageData, other.imageData) + } + + override fun hashCode(): Int { + return arrayOf( + firstName, + lastName, + phoneNumbers, + emailAddresses, + imageData + ).contentDeepHashCode() + } + companion object { /** * Constructor called from C++ diff --git a/modules/ExpensifyNitroUtils/nitrogen/generated/android/kotlin/com/margelo/nitro/utils/StringHolder.kt b/modules/ExpensifyNitroUtils/nitrogen/generated/android/kotlin/com/margelo/nitro/utils/StringHolder.kt index accaebee2968..abe48e289f4e 100644 --- a/modules/ExpensifyNitroUtils/nitrogen/generated/android/kotlin/com/margelo/nitro/utils/StringHolder.kt +++ b/modules/ExpensifyNitroUtils/nitrogen/generated/android/kotlin/com/margelo/nitro/utils/StringHolder.kt @@ -9,6 +9,7 @@ package com.margelo.nitro.utils import androidx.annotation.Keep import com.facebook.proguard.annotations.DoNotStrip +import java.util.Objects /** @@ -23,6 +24,18 @@ data class StringHolder( ) { /* primary constructor */ + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is StringHolder) return false + return Objects.deepEquals(this.value, other.value) + } + + override fun hashCode(): Int { + return arrayOf( + value + ).contentDeepHashCode() + } + companion object { /** * Constructor called from C++ diff --git a/modules/ExpensifyNitroUtils/nitrogen/generated/ios/ExpensifyNitroUtils+autolinking.rb b/modules/ExpensifyNitroUtils/nitrogen/generated/ios/ExpensifyNitroUtils+autolinking.rb index 78c2c451cd92..c72938ed02d8 100644 --- a/modules/ExpensifyNitroUtils/nitrogen/generated/ios/ExpensifyNitroUtils+autolinking.rb +++ b/modules/ExpensifyNitroUtils/nitrogen/generated/ios/ExpensifyNitroUtils+autolinking.rb @@ -56,5 +56,7 @@ def add_nitrogen_files(spec) "SWIFT_OBJC_INTEROP_MODE" => "objcxx", # Enables stricter modular headers "DEFINES_MODULE" => "YES", + # Disable auto-generated ObjC header for Swift (Static linkage on Xcode 26.4 breaks here) + "SWIFT_INSTALL_OBJC_HEADER" => "NO", }) end diff --git a/package-lock.json b/package-lock.json index 21b697d417fe..d333db04f5ce 100644 --- a/package-lock.json +++ b/package-lock.json @@ -121,7 +121,8 @@ "react-native-key-command": "1.0.14", "react-native-keyboard-controller": "1.21.0-beta.1", "react-native-localize": "^3.5.4", - "react-native-nitro-modules": "0.35.0", + "react-native-nitro-fetch": "1.5.0", + "react-native-nitro-modules": "0.35.9", "react-native-nitro-sqlite": "9.6.0", "react-native-onyx": "3.0.90", "react-native-pager-view": "8.0.0", @@ -260,7 +261,7 @@ "lefthook": "2.1.9", "link": "^2.1.1", "memfs": "^4.6.0", - "nitrogen": "0.35.0", + "nitrogen": "0.35.5", "onchange": "^7.1.0", "openai": "^6.16.0", "oxc-transform": "0.136.0", @@ -33579,14 +33580,14 @@ "license": "MIT" }, "node_modules/nitrogen": { - "version": "0.35.0", - "resolved": "https://registry.npmjs.org/nitrogen/-/nitrogen-0.35.0.tgz", - "integrity": "sha512-K8/4h9bCQahi3qEheWZx5joLFsAW3QjK0dVSC3gNLlQhlSJN42UFmffAouOZXYjg9rBDpVlrVo+Hsja45swsJQ==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/nitrogen/-/nitrogen-0.35.5.tgz", + "integrity": "sha512-Ofl4aTW2rd44+hBcUwLXu01ViHikn2SDI7zOYc+6NcKSpnhoj42k1FwyvRj1QZPDMUT64Bwlb0DYw7Hrfd3BfQ==", "dev": true, "license": "MIT", "dependencies": { "chalk": "^5.3.0", - "react-native-nitro-modules": "^0.35.0", + "react-native-nitro-modules": "^0.35.5", "ts-morph": "^27.0.0", "yargs": "^18.0.0", "zod": "^4.0.5" @@ -35828,10 +35829,47 @@ } } }, + "node_modules/react-native-nitro-fetch": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/react-native-nitro-fetch/-/react-native-nitro-fetch-1.5.0.tgz", + "integrity": "sha512-8HSiW0l5eVNhZuRkSprv9Z+OonhSOBkjeMnRvVshJuPXBQzghXidOyrVEO3HXEB+pruJT1OMblCjU7lMyQVD9w==", + "license": "MIT", + "workspaces": [ + "example" + ], + "dependencies": { + "web-streams-polyfill": "^4.2.0" + }, + "peerDependencies": { + "react": "*", + "react-native": "*", + "react-native-nitro-modules": "^0.35.2", + "react-native-worklets": ">=0.8.0" + }, + "peerDependenciesMeta": { + "react-native-worklets": { + "optional": true + } + } + }, + "node_modules/react-native-nitro-fetch/node_modules/web-streams-polyfill": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.3.0.tgz", + "integrity": "sha512-/Gnggvj9oSrEvJbDyyPtAnxBt5fGQM2iWOKQNu7ie1OxDgK40iZpyV3TKaRiEzVj1oA1UxKnEy9XPXh6PW3eVw==", + "license": "MIT", + "workspaces": [ + "test/benchmark-test", + "test/rollup-test", + "test/webpack-test" + ], + "engines": { + "node": ">= 8" + } + }, "node_modules/react-native-nitro-modules": { - "version": "0.35.0", - "resolved": "https://registry.npmjs.org/react-native-nitro-modules/-/react-native-nitro-modules-0.35.0.tgz", - "integrity": "sha512-Eho1yEcLbsteGpBFn2XZOp5FIptnEciWzuYBW49S0jo41Un2LeyesIO/MqYLY/c5o7D9Fw9th4pxGtV7OAb0+g==", + "version": "0.35.9", + "resolved": "https://registry.npmjs.org/react-native-nitro-modules/-/react-native-nitro-modules-0.35.9.tgz", + "integrity": "sha512-yCO6eJ85SPPUo4a4an7H5oj6wPCSIT72fbjr5WZ/20n6zswaJ2gNNpnWtg2We0AZwkAOjSqkOJ0Vjc05p6kGiA==", "license": "MIT", "peerDependencies": { "react": "*", diff --git a/package.json b/package.json index 52ef51f3e6be..814a65ab4f68 100644 --- a/package.json +++ b/package.json @@ -195,7 +195,8 @@ "react-native-key-command": "1.0.14", "react-native-keyboard-controller": "1.21.0-beta.1", "react-native-localize": "^3.5.4", - "react-native-nitro-modules": "0.35.0", + "react-native-nitro-fetch": "1.5.0", + "react-native-nitro-modules": "0.35.9", "react-native-nitro-sqlite": "9.6.0", "react-native-onyx": "3.0.90", "react-native-pager-view": "8.0.0", @@ -334,7 +335,7 @@ "lefthook": "2.1.9", "link": "^2.1.1", "memfs": "^4.6.0", - "nitrogen": "0.35.0", + "nitrogen": "0.35.5", "onchange": "^7.1.0", "openai": "^6.16.0", "oxc-transform": "0.136.0", diff --git a/patches/details.md b/patches/details.md new file mode 100644 index 000000000000..b31e2d96203c --- /dev/null +++ b/patches/details.md @@ -0,0 +1,17 @@ +# `react-native-nitro-fetch` patches + +### [react-native-nitro-fetch+1.5.0.patch](react-native-nitro-fetch+1.5.0.patch) + +- Reason: + + ``` + NitroFetch bypasses the app's existing certificate-pinning clients on mobile. This patch applies + Expensify's public-key pins to both Android Cronet engines, supports monitor-only reporting via + a separate pinned Cronet probe, enforces pins on real traffic when enabled, and gives every iOS + URLSession a delegate so TrustKit can validate standard requests, startup prefetches, token + refreshes, and streaming requests. + ``` + +- Upstream PR/issue: None. The certificate hosts and public-key pins are specific to Expensify's application configuration and are not appropriate defaults for react-native-nitro-fetch. +- E/App issue: https://github.com/Expensify/App/issues/90023 +- PR Introducing Patch: https://github.com/Expensify/App/pull/95518 diff --git a/patches/react-native-nitro-fetch+1.5.0.patch b/patches/react-native-nitro-fetch+1.5.0.patch new file mode 100644 index 000000000000..ad913a331c72 --- /dev/null +++ b/patches/react-native-nitro-fetch+1.5.0.patch @@ -0,0 +1,332 @@ +diff --git a/node_modules/react-native-nitro-fetch/android/build.gradle b/node_modules/react-native-nitro-fetch/android/build.gradle +index bd570da..eabe02a 100644 +--- a/node_modules/react-native-nitro-fetch/android/build.gradle ++++ b/node_modules/react-native-nitro-fetch/android/build.gradle +@@ -139,6 +139,7 @@ dependencies { + // Provide org.chromium.net Java API for CronetEngine in Kotlin + // Cronet + api "org.chromium.net:cronet-embedded:${cronetVersion}" ++ compileOnly "io.sentry:sentry-android:8.33.0" + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0" + + } +diff --git a/node_modules/react-native-nitro-fetch/android/src/main/java/com/margelo/nitro/nitrofetch/ExpensifyCertificatePins.kt b/node_modules/react-native-nitro-fetch/android/src/main/java/com/margelo/nitro/nitrofetch/ExpensifyCertificatePins.kt +new file mode 100644 +index 0000000..a432432 +--- /dev/null ++++ b/node_modules/react-native-nitro-fetch/android/src/main/java/com/margelo/nitro/nitrofetch/ExpensifyCertificatePins.kt +@@ -0,0 +1,181 @@ ++package com.margelo.nitro.nitrofetch ++ ++import android.content.Context ++import android.net.Uri ++import io.sentry.Sentry ++import io.sentry.SentryLevel ++import org.chromium.net.CronetEngine ++import org.chromium.net.CronetException ++import org.chromium.net.NetworkException ++import org.chromium.net.UrlRequest ++import org.chromium.net.UrlResponseInfo ++import java.util.Date ++import java.util.concurrent.ConcurrentHashMap ++import java.util.concurrent.Executors ++import javax.net.ssl.SSLPeerUnverifiedException ++ ++/** ++ * Certificate pinning for the Cronet engines (Expensify Iteration 1 - NewDot). ++ * ++ * Patched into react-native-nitro-fetch via patch-package. Cronet uses its own BoringSSL trust stack ++ * and does NOT honor Android's network_security_config.xml, so public-key pins are applied directly ++ * to each CronetEngine.Builder in enforce mode. Monitor mode uses a separate pinned HEAD probe so ++ * the real request remains unblocked while pin mismatches are reported to Sentry. ++ * ++ * Each host pins the leaf SPKI hash (primary) plus its issuing intermediate CA SPKI hash (durable ++ * backup that survives leaf rotation). Keep in sync with config/certificatePinning/pins.json, ++ * android/app/src/main/res/xml/network_security_config.xml, CertificatePinning.kt and CertificatePinning.swift. ++ */ ++internal object ExpensifyCertificatePins { ++ // Keep in sync with config/certificatePinning/pins.json and the platform implementations. ++ private const val ENFORCE_PINNING = false ++ private const val PIN_MISMATCH_ERROR_CODE = -150 ++ private const val CERTIFICATE_PINNING_HOST_TAG = "certificate_pinning_host" ++ private const val CERTIFICATE_PINNING_MODE_TAG = "certificate_pinning_mode" ++ private const val CERTIFICATE_PINNING_CHANNEL_TAG = "certificate_pinning_channel" ++ ++ // 2100-01-01 UTC. Cronet requires a non-null pin expiration; rotation is managed via app updates. ++ private val PIN_EXPIRATION = Date(4102444800000L) ++ private val monitorExecutor = Executors.newSingleThreadExecutor { runnable -> ++ Thread(runnable, "NitroFetch-pin-monitor").apply { isDaemon = true } ++ } ++ private val monitoredHosts = ConcurrentHashMap.newKeySet() ++ ++ @Volatile ++ private var monitorEngine: CronetEngine? = null ++ ++ private fun decode(base64Hash: String): ByteArray = android.util.Base64.decode(base64Hash, android.util.Base64.DEFAULT) ++ ++ private val pinnedDomains: Map> = mapOf( ++ "www.expensify.com" to pins("cSP5K9Slk59AgwZPst+dLPuNE+ZhypUlYRQNW1XC/fc=", "brzvtCELCIZUo4sD/qPX0ccRtPsd3DY6RfmxpOU9oB4="), ++ "secure.expensify.com" to pins("cSP5K9Slk59AgwZPst+dLPuNE+ZhypUlYRQNW1XC/fc=", "brzvtCELCIZUo4sD/qPX0ccRtPsd3DY6RfmxpOU9oB4="), ++ "staging.expensify.com" to pins("cSP5K9Slk59AgwZPst+dLPuNE+ZhypUlYRQNW1XC/fc=", "brzvtCELCIZUo4sD/qPX0ccRtPsd3DY6RfmxpOU9oB4="), ++ "staging-secure.expensify.com" to pins("cSP5K9Slk59AgwZPst+dLPuNE+ZhypUlYRQNW1XC/fc=", "brzvtCELCIZUo4sD/qPX0ccRtPsd3DY6RfmxpOU9oB4="), ++ "new.expensify.com" to pins("G2v6PWWl92F5vVHCtAYwScBHqNtPMkxb++SFoBJq5F4=", "kIdp6NNEd8wsugYyyIYFsi1ylMCED3hZbSR8ZFsa/A4="), ++ "staging.new.expensify.com" to pins("G2v6PWWl92F5vVHCtAYwScBHqNtPMkxb++SFoBJq5F4=", "kIdp6NNEd8wsugYyyIYFsi1ylMCED3hZbSR8ZFsa/A4="), ++ "integrations.expensify.com" to pins("7D0dEgdEKEMYRTgVwvnhJv19B4apk0QM/GPnRAKRGUs=", "AlSQhgtJirc8ahLyekmtX+Iw+v46yPYRLJt9Cq1GlB0="), ++ "travel.expensify.com" to pins("Qb3qmTdRt/xHEN5PVtn+YhKoGqF/lhRX88cSFuSCJqM=", "kIdp6NNEd8wsugYyyIYFsi1ylMCED3hZbSR8ZFsa/A4="), ++ "staging.travel.expensify.com" to pins("Qb3qmTdRt/xHEN5PVtn+YhKoGqF/lhRX88cSFuSCJqM=", "kIdp6NNEd8wsugYyyIYFsi1ylMCED3hZbSR8ZFsa/A4="), ++ "d2k5nsl2zxldvw.cloudfront.net" to pins("P9HBoLji8YncXSnb0AnAm72fJO/vpmxZrsl4fvUBkxc=", "DxH4tt40L+eduF6szpY6TONlxhZhBd+pJ9wbHlQ2fuw="), ++ ) ++ ++ private fun pins(primary: String, backup: String): Set = setOf(decode(primary), decode(backup)) ++ ++ fun apply(builder: CronetEngine.Builder, context: Context) { ++ if (BuildConfig.DEBUG) { ++ return ++ } ++ ++ if (ENFORCE_PINNING) { ++ applyPins(builder) ++ return ++ } ++ ++ ensureMonitorEngine(context.applicationContext) ++ } ++ ++ fun monitor(url: String) { ++ if (BuildConfig.DEBUG || ENFORCE_PINNING) { ++ return ++ } ++ ++ val uri = Uri.parse(url) ++ if (!uri.scheme.equals("https", ignoreCase = true)) { ++ return ++ } ++ ++ val host = uri.host?.lowercase() ?: return ++ if (!pinnedDomains.containsKey(host) || !monitoredHosts.add(host)) { ++ return ++ } ++ ++ val engine = monitorEngine ++ if (engine == null) { ++ monitoredHosts.remove(host) ++ return ++ } ++ ++ val callback = object : UrlRequest.Callback() { ++ override fun onRedirectReceived(request: UrlRequest, info: UrlResponseInfo, newLocationUrl: String) { ++ request.cancel() ++ } ++ ++ override fun onResponseStarted(request: UrlRequest, info: UrlResponseInfo) { ++ // A response proves the pinned TLS handshake succeeded; the body is irrelevant. ++ request.cancel() ++ } ++ ++ override fun onReadCompleted(request: UrlRequest, info: UrlResponseInfo, byteBuffer: java.nio.ByteBuffer) = Unit ++ ++ override fun onSucceeded(request: UrlRequest, info: UrlResponseInfo) = Unit ++ ++ override fun onFailed(request: UrlRequest, info: UrlResponseInfo?, error: CronetException) { ++ if (isPinMismatch(error)) { ++ reportPinningFailure(host, error) ++ } else { ++ // Retry on a later request if this probe failed for an unrelated transient reason. ++ monitoredHosts.remove(host) ++ } ++ } ++ } ++ ++ try { ++ engine.newUrlRequestBuilder("https://$host/", callback, monitorExecutor) ++ .setHttpMethod("HEAD") ++ .disableCache() ++ .build() ++ .start() ++ } catch (_: Throwable) { ++ monitoredHosts.remove(host) ++ } ++ } ++ ++ fun reportIfPinFailure(url: String, error: CronetException) { ++ if (!ENFORCE_PINNING || !isPinMismatch(error)) { ++ return ++ } ++ ++ val host = Uri.parse(url).host?.lowercase() ?: return ++ if (pinnedDomains.containsKey(host)) { ++ reportPinningFailure(host, error) ++ } ++ } ++ ++ @Synchronized ++ private fun ensureMonitorEngine(context: Context) { ++ if (monitorEngine != null) { ++ return ++ } ++ ++ val builder = CronetEngine.Builder(context) ++ .enableHttp2(true) ++ .enableQuic(true) ++ .enableBrotli(true) ++ .enableHttpCache(CronetEngine.Builder.HTTP_CACHE_DISABLED, 0) ++ .setUserAgent("NitroFetchPinMonitor/1.0") ++ applyPins(builder) ++ monitorEngine = builder.build() ++ } ++ ++ private fun applyPins(builder: CronetEngine.Builder) { ++ // Cronet bypasses configured pins for locally installed trust anchors by default. ++ builder.enablePublicKeyPinningBypassForLocalTrustAnchors(false) ++ pinnedDomains.forEach { (host, hashes) -> ++ builder.addPublicKeyPins(host, hashes, false, PIN_EXPIRATION) ++ } ++ } ++ ++ private fun isPinMismatch(error: CronetException): Boolean = ++ error is NetworkException && error.cronetInternalErrorCode == PIN_MISMATCH_ERROR_CODE ++ ++ private fun reportPinningFailure(host: String, cause: Throwable) { ++ val error = SSLPeerUnverifiedException("Certificate pinning validation failed for $host") ++ error.initCause(cause) ++ Sentry.captureException(error) { scope -> ++ scope.level = SentryLevel.WARNING ++ scope.setTag(CERTIFICATE_PINNING_HOST_TAG, host) ++ scope.setTag(CERTIFICATE_PINNING_MODE_TAG, if (ENFORCE_PINNING) "enforce" else "monitor") ++ scope.setTag(CERTIFICATE_PINNING_CHANNEL_TAG, "cronet") ++ } ++ } ++} +diff --git a/node_modules/react-native-nitro-fetch/android/src/main/java/com/margelo/nitro/nitrofetch/NitroCronet.kt b/node_modules/react-native-nitro-fetch/android/src/main/java/com/margelo/nitro/nitrofetch/NitroCronet.kt +index 3554aad..8e6fc4c 100644 +--- a/node_modules/react-native-nitro-fetch/android/src/main/java/com/margelo/nitro/nitrofetch/NitroCronet.kt ++++ b/node_modules/react-native-nitro-fetch/android/src/main/java/com/margelo/nitro/nitrofetch/NitroCronet.kt +@@ -64,6 +64,9 @@ class NitroCronet : HybridNitroCronetSpec() { + (50 * 1024 * 1024).toLong() + ) + ++ // Certificate pinning (Expensify) - Cronet does not honor network_security_config.xml. ++ ExpensifyCertificatePins.apply(builder, app) ++ + val engine = builder.build() + engineRef = engine + return engine +diff --git a/node_modules/react-native-nitro-fetch/android/src/main/java/com/margelo/nitro/nitrofetch/NitroFetch.kt b/node_modules/react-native-nitro-fetch/android/src/main/java/com/margelo/nitro/nitrofetch/NitroFetch.kt +index ff872eb..ee1de2a 100644 +--- a/node_modules/react-native-nitro-fetch/android/src/main/java/com/margelo/nitro/nitrofetch/NitroFetch.kt ++++ b/node_modules/react-native-nitro-fetch/android/src/main/java/com/margelo/nitro/nitrofetch/NitroFetch.kt +@@ -60,6 +60,9 @@ class NitroFetch : HybridNitroFetchSpec() { + // Prove DNS issues by mapping a host (TESTING ONLY, remove in prod): + // builder.setExperimentalOptions("""{"HostResolverRules":{"host_resolver_rules":"MAP httpbin.org 54.167.17.38"}}""") + ++ // Certificate pinning (Expensify) - Cronet does not honor network_security_config.xml. ++ ExpensifyCertificatePins.apply(builder, app) ++ + val engine = builder.build() + Log.i("NitroFetch", "CronetEngine initialized. Provider=${nativeProvider?.name ?: "Default"} Cache=${cacheDir.absolutePath}") + engineRef = engine +diff --git a/node_modules/react-native-nitro-fetch/android/src/main/java/com/margelo/nitro/nitrofetch/NitroFetchClient.kt b/node_modules/react-native-nitro-fetch/android/src/main/java/com/margelo/nitro/nitrofetch/NitroFetchClient.kt +index 308e465..9a59ee4 100644 +--- a/node_modules/react-native-nitro-fetch/android/src/main/java/com/margelo/nitro/nitrofetch/NitroFetchClient.kt ++++ b/node_modules/react-native-nitro-fetch/android/src/main/java/com/margelo/nitro/nitrofetch/NitroFetchClient.kt +@@ -107,6 +107,7 @@ class NitroFetchClient(private val engine: CronetEngine, private val executor: E + onFail: (Throwable) -> Unit + ): UrlRequest { + val url = req.url ++ ExpensifyCertificatePins.monitor(url) + val shouldFollowRedirects = req.followRedirects ?: true + val omitCredentials = req.credentials == NitroRequestCredentials.OMIT + val traceLabel = if (BuildConfig.NITRO_FETCH_TRACING) { +@@ -250,6 +251,7 @@ class NitroFetchClient(private val engine: CronetEngine, private val executor: E + } + + override fun onFailed(request: UrlRequest, info: UrlResponseInfo?, error: CronetException) { ++ ExpensifyCertificatePins.reportIfPinFailure(url, error) + if (BuildConfig.NITRO_FETCH_TRACING) { + Trace.endAsyncSection(traceLabel, traceCookie) + } +diff --git a/node_modules/react-native-nitro-fetch/android/src/main/java/com/margelo/nitro/nitrofetch/NitroUrlRequestBuilder.kt b/node_modules/react-native-nitro-fetch/android/src/main/java/com/margelo/nitro/nitrofetch/NitroUrlRequestBuilder.kt +index 3937669..28c240b 100644 +--- a/node_modules/react-native-nitro-fetch/android/src/main/java/com/margelo/nitro/nitrofetch/NitroUrlRequestBuilder.kt ++++ b/node_modules/react-native-nitro-fetch/android/src/main/java/com/margelo/nitro/nitrofetch/NitroUrlRequestBuilder.kt +@@ -120,6 +120,7 @@ class NitroUrlRequestBuilder( + info: CronetUrlResponseInfo?, + error: CronetNativeException + ) { ++ ExpensifyCertificatePins.reportIfPinFailure(url, error) + if (devToolsEnabled) { + DevToolsReporter.reportRequestFailed(devToolsRequestId, false) + } +@@ -229,6 +230,7 @@ class NitroUrlRequestBuilder( + } + + override fun build(): HybridUrlRequestSpec { ++ ExpensifyCertificatePins.monitor(url) + val cronetRequest = builder.build() + if (devToolsEnabled) { + DevToolsReporter.reportRequestStart( +diff --git a/node_modules/react-native-nitro-fetch/ios/HybridNitroCronet.swift b/node_modules/react-native-nitro-fetch/ios/HybridNitroCronet.swift +index 344d1ef..fe11b64 100644 +--- a/node_modules/react-native-nitro-fetch/ios/HybridNitroCronet.swift ++++ b/node_modules/react-native-nitro-fetch/ios/HybridNitroCronet.swift +@@ -11,7 +11,7 @@ class HybridNitroCronet: HybridNitroCronetSpec { + diskCapacity: 100 * 1024 * 1024, + diskPath: "nitrofetch_urlcache" + ) +- return URLSession(configuration: config) ++ return ExpensifyPinnedURLSession.make(configuration: config) + }() + + // Shared executor queue +diff --git a/node_modules/react-native-nitro-fetch/ios/NitroAutoPrefetcher.swift b/node_modules/react-native-nitro-fetch/ios/NitroAutoPrefetcher.swift +index 84cf85c..606d595 100644 +--- a/node_modules/react-native-nitro-fetch/ios/NitroAutoPrefetcher.swift ++++ b/node_modules/react-native-nitro-fetch/ios/NitroAutoPrefetcher.swift +@@ -319,7 +319,7 @@ public final class NitroAutoPrefetcher: NSObject { + request.httpBody = body.data(using: .utf8) + } + +- let (data, response) = try await URLSession.shared.data(for: request) ++ let (data, response) = try await ExpensifyPinnedURLSession.shared.data(for: request) + guard let http = response as? HTTPURLResponse, + (200...299).contains(http.statusCode) else { + throw NSError(domain: "NitroAutoPrefetcher", code: -2, +diff --git a/node_modules/react-native-nitro-fetch/ios/NitroFetchClient.swift b/node_modules/react-native-nitro-fetch/ios/NitroFetchClient.swift +index 8e2c84b..f808f4c 100644 +--- a/node_modules/react-native-nitro-fetch/ios/NitroFetchClient.swift ++++ b/node_modules/react-native-nitro-fetch/ios/NitroFetchClient.swift +@@ -7,6 +7,25 @@ import UniformTypeIdentifiers + private let fetchLog = OSLog(subsystem: "com.margelo.nitrofetch", category: "network") + #endif + ++/** ++ * Expensify initializes TrustKit with URLSession delegate swizzling in release builds. TrustKit ++ * cannot observe sessions created without a delegate, so all NitroFetch sessions must use this ++ * delegate to keep certificate pinning active. ++ */ ++final class ExpensifyPinnedURLSessionDelegate: NSObject, URLSessionDelegate {} ++ ++enum ExpensifyPinnedURLSession { ++ static let shared = make(configuration: .default) ++ ++ static func make(configuration: URLSessionConfiguration) -> URLSession { ++ return URLSession( ++ configuration: configuration, ++ delegate: ExpensifyPinnedURLSessionDelegate(), ++ delegateQueue: nil ++ ) ++ } ++} ++ + final class NitroFetchClient: HybridNitroFetchClientSpec { + + private var _lock = os_unfair_lock() +@@ -101,7 +120,7 @@ final class NitroFetchClient: HybridNitroFetchClientSpec { + config.urlCache = URLCache(memoryCapacity: 32 * 1024 * 1024, + diskCapacity: 100 * 1024 * 1024, + diskPath: "nitrofetch_urlcache") +- return URLSession(configuration: config) ++ return ExpensifyPinnedURLSession.make(configuration: config) + }() + + private static func findPrefetchKey(_ req: NitroRequest) -> String? { diff --git a/scripts/generateCertificatePins.sh b/scripts/generateCertificatePins.sh index b006464da7df..8a5c63510112 100755 --- a/scripts/generateCertificatePins.sh +++ b/scripts/generateCertificatePins.sh @@ -21,6 +21,7 @@ # - android/app/src/main/res/xml/network_security_config_enforce.xml # - android/app/src/main/java/com/expensify/chat/CertificatePinning.kt # - ios/CertificatePinning.swift +# - patches/react-native-nitro-fetch+1.5.0.patch # - Mobile-Expensify/Android/res/xml/network_security_config_enforce.xml # - Mobile-Expensify/Android/src/yapl/android/http/ExpensifyCertificatePinner.java # - Mobile-Expensify/iOS/Expensify/ExpensifyAppDelegate.m (TrustKit kTSKPublicKeyHashes) diff --git a/server/stubs/react-native-nitro-fetch.ts b/server/stubs/react-native-nitro-fetch.ts index 6824519e84a0..704ff3387e20 100644 --- a/server/stubs/react-native-nitro-fetch.ts +++ b/server/stubs/react-native-nitro-fetch.ts @@ -2,6 +2,10 @@ function clearTokenRefresh() {} function prefetchOnAppStart() {} -export {clearTokenRefresh, prefetchOnAppStart}; +function registerTokenRefresh() {} + +function removeFromAutoPrefetch() {} + +export {clearTokenRefresh, prefetchOnAppStart, registerTokenRefresh, removeFromAutoPrefetch}; export default {}; diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 00de7b87ae51..6f830096eac8 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -2482,6 +2482,9 @@ const CONST = { GATEWAY_TIMEOUT: 504, UNKNOWN_ERROR: 520, }, + HTTP_HEADER_NAMES: { + AUTH_TOKEN: 'authToken', + }, ERROR: { XHR_FAILED: 'xhrFailed', THROTTLED: 'throttled', diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index e57c87cf6359..6db64f2167ce 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -10,6 +10,8 @@ import type * as Parameters from './parameters'; import type SignInUserParams from './parameters/SignInUserParams'; import type UpdateBeneficialOwnersForBankAccountParams from './parameters/UpdateBeneficialOwnersForBankAccountParams'; +const AUTHENTICATION_COMMAND = 'Authenticate' as const; + type ApiRequestType = ValueOf; const WRITE_COMMANDS = { @@ -1639,7 +1641,7 @@ type SideEffectRequestCommandParameters = { type ApiRequestCommandParameters = WriteCommandParameters & ReadCommandParameters & SideEffectRequestCommandParameters; -export {WRITE_COMMANDS, READ_COMMANDS, SIDE_EFFECT_REQUEST_COMMANDS}; +export {WRITE_COMMANDS, READ_COMMANDS, SIDE_EFFECT_REQUEST_COMMANDS, AUTHENTICATION_COMMAND}; type ApiCommand = WriteCommand | ReadCommand | SideEffectRequestCommand; type CommandOfType = TRequestType extends typeof CONST.API_REQUEST_TYPE.WRITE diff --git a/src/libs/HttpUtils.ts b/src/libs/HttpUtils.ts index bc4c944a31a6..2dd799f5c9c6 100644 --- a/src/libs/HttpUtils.ts +++ b/src/libs/HttpUtils.ts @@ -5,6 +5,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type {RequestType} from '@src/types/onyx/Request'; import type Response from '@src/types/onyx/Response'; +import type {fetch as nitroFetch} from 'react-native-nitro-fetch'; import type {OnyxKey} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; @@ -16,6 +17,8 @@ import {READ_COMMANDS, SIDE_EFFECT_REQUEST_COMMANDS, WRITE_COMMANDS} from './API import {getCommandURL} from './ApiUtils'; import HttpsError from './Errors/HttpsError'; import {setLoadTestParameters} from './Network/LoadTestState'; +import preparePrefetchRequest from './Prefetch/preparePrefetchRequest'; +import registerPrefetchOnAppStart from './Prefetch/registerPrefetchOnAppStart'; import prepareRequestPayload from './prepareRequestPayload'; import markAppStartupNetworkRequestEnd from './telemetry/markAppStartupNetworkRequestEnd'; @@ -78,17 +81,24 @@ function processHTTPRequest( const command = url.match(APICommandRegex)?.[1]; - return fetch(url, { + const {prefetchKey, prefetchHeaders} = preparePrefetchRequest(command); + + const fetchParams: NonNullable[1]> = { // We hook requests to the same Controller signal, so we can cancel them all at once signal: abortSignal, method, body, + headers: prefetchHeaders, // On Web fetch already defaults to 'omit' for credentials, but it seems that this is not the case for the ReactNative implementation // so to avoid sending cookies with the request we set it to 'omit' explicitly // this avoids us sending specially the expensifyWeb cookie, which makes a CSRF token required // more on that here: https://stackoverflowteams.com/c/expensify/questions/93 credentials: 'omit', - }) + }; + + registerPrefetchOnAppStart({prefetchKey, fetchParams, command, url}); + + return fetch(url, fetchParams) .then((response) => { if (response.headers) { setLoadTestParameters(response.headers.get('X-Load-Test')); diff --git a/src/libs/Network/enhanceParameters.ts b/src/libs/Network/enhanceParameters.ts index 4e397d44d742..a6f7de574105 100644 --- a/src/libs/Network/enhanceParameters.ts +++ b/src/libs/Network/enhanceParameters.ts @@ -1,3 +1,4 @@ +import {AUTHENTICATION_COMMAND} from '@libs/API/types'; import * as Environment from '@libs/Environment/Environment'; import getPlatform from '@libs/getPlatform'; @@ -50,34 +51,38 @@ Onyx.connectWithoutView({ * Does this command require an authToken? */ function isAuthTokenRequired(command: string): boolean { - return !['Log', 'Authenticate', 'BeginSignIn', 'SetPassword'].includes(command); + return !['Log', AUTHENTICATION_COMMAND, 'BeginSignIn', 'SetPassword'].includes(command); +} + +/** + * Returns request metadata shared by every API call (including Authenticate). + */ +function getBaseRequestParameters(email?: unknown) { + return { + referer: CONFIG.EXPENSIFY.EXPENSIFY_CASH_REFERER, + platform: getPlatform(), + // This application does not save its authToken in cookies like the classic Expensify app. + // Setting api_setCookie to false will ensure that the Expensify API doesn't set any cookies + // and prevents interfering with the cookie authToken that Expensify classic uses. + // eslint-disable-next-line @typescript-eslint/naming-convention + api_setCookie: false, + // Include current user's email in every request and the server logs + email: email ?? getCurrentUserEmail(), + isFromDevEnv: Environment.isDevelopment(), + appversion: pkg.version, + }; } /** * Adds default values to our request data */ export default function enhanceParameters(command: string, parameters: Record): Record { - const finalParameters = {...parameters}; + const finalParameters: Record = {...parameters, ...getBaseRequestParameters(parameters.email)}; if (isAuthTokenRequired(command) && !parameters.authToken) { finalParameters.authToken = getAuthToken() ?? null; } - finalParameters.referer = CONFIG.EXPENSIFY.EXPENSIFY_CASH_REFERER; - - // In addition to the referer (ecash), we pass the platform to help differentiate what device type - // is sending the request. - finalParameters.platform = getPlatform(); - - // This application does not save its authToken in cookies like the classic Expensify app. - // Setting api_setCookie to false will ensure that the Expensify API doesn't set any cookies - // and prevents interfering with the cookie authToken that Expensify classic uses. - finalParameters.api_setCookie = false; - - // Include current user's email in every request and the server logs - finalParameters.email = parameters.email ?? getCurrentUserEmail(); - finalParameters.isFromDevEnv = Environment.isDevelopment(); - finalParameters.appversion = pkg.version; finalParameters.clientUpdateID = lastUpdateIDAppliedToClient; if (delegate) { finalParameters.delegate = delegate; @@ -88,3 +93,5 @@ export default function enhanceParameters(command: string, parameters: Record([WRITE_COMMANDS.RECONNECT_APP]); + +export default PrefetchQueries; diff --git a/src/libs/Prefetch/PrefetchQueries/index.ts b/src/libs/Prefetch/PrefetchQueries/index.ts new file mode 100644 index 000000000000..da26d587fbc3 --- /dev/null +++ b/src/libs/Prefetch/PrefetchQueries/index.ts @@ -0,0 +1,8 @@ +/** + * Native-only prefetching via `react-native-nitro-fetch` is unavailable on web, so no commands are prefetched. + * Keeping this empty also prevents the `prefetchKey` request header from being added on web, which would otherwise + * trigger a CORS preflight on cross-origin requests. + */ +const PrefetchQueries = new Set(); + +export default PrefetchQueries; diff --git a/src/libs/Prefetch/clearPrefetchOnAppStart/index.ts b/src/libs/Prefetch/clearPrefetchOnAppStart/index.ts new file mode 100644 index 000000000000..bf9d53d5c758 --- /dev/null +++ b/src/libs/Prefetch/clearPrefetchOnAppStart/index.ts @@ -0,0 +1,18 @@ +import Log from '@libs/Log'; +import PrefetchQueries from '@libs/Prefetch/PrefetchQueries'; + +import {clearTokenRefresh, removeFromAutoPrefetch} from 'react-native-nitro-fetch'; + +import type ClearPrefetchOnAppStart from './types'; + +const clearPrefetchOnAppStart: ClearPrefetchOnAppStart = () => { + clearTokenRefresh('fetch'); + + for (const command of PrefetchQueries) { + removeFromAutoPrefetch(command).catch((error) => { + Log.warn(`[HttpUtils] removeFromAutoPrefetch failed for ${command}`, {error}); + }); + } +}; + +export default clearPrefetchOnAppStart; diff --git a/src/libs/Prefetch/clearPrefetchOnAppStart/index.web.ts b/src/libs/Prefetch/clearPrefetchOnAppStart/index.web.ts new file mode 100644 index 000000000000..15bd103ee1e4 --- /dev/null +++ b/src/libs/Prefetch/clearPrefetchOnAppStart/index.web.ts @@ -0,0 +1,5 @@ +import type ClearPrefetchOnAppStart from './types'; + +const clearPrefetchOnAppStart: ClearPrefetchOnAppStart = () => {}; + +export default clearPrefetchOnAppStart; diff --git a/src/libs/Prefetch/clearPrefetchOnAppStart/types.ts b/src/libs/Prefetch/clearPrefetchOnAppStart/types.ts new file mode 100644 index 000000000000..567735ad2adc --- /dev/null +++ b/src/libs/Prefetch/clearPrefetchOnAppStart/types.ts @@ -0,0 +1,3 @@ +type ClearPrefetchOnAppStart = () => void; + +export default ClearPrefetchOnAppStart; diff --git a/src/libs/Prefetch/preparePrefetchRequest/index.ts b/src/libs/Prefetch/preparePrefetchRequest/index.ts new file mode 100644 index 000000000000..4e3f478d938a --- /dev/null +++ b/src/libs/Prefetch/preparePrefetchRequest/index.ts @@ -0,0 +1,22 @@ +import PrefetchQueries from '@libs/Prefetch/PrefetchQueries'; + +import type PreparePrefetchRequest from './types'; + +const preparePrefetchRequest: PreparePrefetchRequest = (command) => { + // Prefetch the request on next app start if the prefetch key is present in the headers + // This allows to fetch the request natively before the JS bundle is loaded. Once the request with this prefetch key is made, it will already be cached and served from the cache. + const prefetchKey = command && PrefetchQueries.has(command) ? command : undefined; + + const prefetchHeaders = prefetchKey + ? { + prefetchKey, + } + : undefined; + + return { + prefetchKey, + prefetchHeaders, + }; +}; + +export default preparePrefetchRequest; diff --git a/src/libs/Prefetch/preparePrefetchRequest/index.web.ts b/src/libs/Prefetch/preparePrefetchRequest/index.web.ts new file mode 100644 index 000000000000..07d3f41f8d63 --- /dev/null +++ b/src/libs/Prefetch/preparePrefetchRequest/index.web.ts @@ -0,0 +1,7 @@ +import type PreparePrefetchRequest from './types'; + +const NOOP: PreparePrefetchRequest = () => ({}); + +const preparePrefetchRequest = NOOP; + +export default preparePrefetchRequest; diff --git a/src/libs/Prefetch/preparePrefetchRequest/types.ts b/src/libs/Prefetch/preparePrefetchRequest/types.ts new file mode 100644 index 000000000000..fded935a40ca --- /dev/null +++ b/src/libs/Prefetch/preparePrefetchRequest/types.ts @@ -0,0 +1,8 @@ +type PreparePrefetchRequestResult = { + prefetchKey?: string; + prefetchHeaders?: Record; +}; + +type PreparePrefetchRequest = (command: string | undefined) => PreparePrefetchRequestResult; + +export default PreparePrefetchRequest; diff --git a/src/libs/Prefetch/registerPrefetchOnAppStart/index.ts b/src/libs/Prefetch/registerPrefetchOnAppStart/index.ts new file mode 100644 index 000000000000..92508eeb3a8d --- /dev/null +++ b/src/libs/Prefetch/registerPrefetchOnAppStart/index.ts @@ -0,0 +1,80 @@ +import {AUTHENTICATION_COMMAND} from '@libs/API/types'; +import {getApiRoot} from '@libs/ApiUtils'; +import Log from '@libs/Log'; +import {getBaseRequestParameters} from '@libs/Network/enhanceParameters'; +import {getCredentials} from '@libs/Network/NetworkStore'; +import {getPartnerCredentials} from '@libs/SessionUtils'; + +import CONST from '@src/CONST'; +import type Credentials from '@src/types/onyx/Credentials'; + +import {clearTokenRefresh, prefetchOnAppStart, registerTokenRefresh} from 'react-native-nitro-fetch'; + +import type RegisterPrefetchOnAppStart from './types'; + +const NITRO_FETCH_TARGET = 'fetch'; +const CONTENT_TYPE_HEADER = 'Content-Type'; + +function buildAuthenticateBody(credentials: Credentials): string { + // NitroFetch token refresh runs outside the JS request pipeline, so we must serialize the + // Authenticate body here instead of going through Request.post() and enhanceParameters(). + const {partnerName, partnerPassword} = getPartnerCredentials(credentials.autoGeneratedLogin); + // eslint-disable-next-line @typescript-eslint/naming-convention + const {referer, platform, api_setCookie, isFromDevEnv} = getBaseRequestParameters(); + + const body = new URLSearchParams(); + body.append('useExpensifyLogin', 'false'); + body.append('partnerName', partnerName); + body.append('partnerPassword', partnerPassword); + body.append('partnerUserID', credentials.autoGeneratedLogin ?? ''); + body.append('partnerUserSecret', credentials.autoGeneratedPassword ?? ''); + body.append('shouldRetry', 'false'); + body.append('forceNetworkRequest', 'true'); + body.append('email', credentials.login ?? ''); + body.append('referer', referer); + body.append('platform', platform); + body.append('api_setCookie', String(api_setCookie)); + body.append('isFromDevEnv', String(isFromDevEnv)); + + return body.toString(); +} + +function registerPrefetchTokenRefresh(): void { + const credentials = getCredentials(); + + clearTokenRefresh(NITRO_FETCH_TARGET); + + if (!credentials?.autoGeneratedLogin || !credentials.autoGeneratedPassword) { + return; + } + + const authenticateURL = `${getApiRoot()}api/${AUTHENTICATION_COMMAND}?`; + + registerTokenRefresh({ + target: NITRO_FETCH_TARGET, + url: authenticateURL, + method: 'POST', + headers: { + [CONTENT_TYPE_HEADER]: 'application/x-www-form-urlencoded', + }, + body: buildAuthenticateBody(credentials), + responseType: 'json', + formDataMappings: [{jsonPath: CONST.HTTP_HEADER_NAMES.AUTH_TOKEN, field: CONST.HTTP_HEADER_NAMES.AUTH_TOKEN}], + onFailure: 'useStoredHeaders', + }); + + Log.info('[NitroFetchTokenRefresh] Registered token refresh for startup prefetch', false); +} + +const registerPrefetchOnAppStart: RegisterPrefetchOnAppStart = ({prefetchKey, fetchParams, command, url}) => { + if (!prefetchKey) { + return; + } + + registerPrefetchTokenRefresh(); + prefetchOnAppStart(url, fetchParams).catch((error) => { + Log.warn(`[HttpUtils] prefetchOnAppStart failed for ${command}`, {error, fetchParams, url}); + }); +}; + +export default registerPrefetchOnAppStart; diff --git a/src/libs/Prefetch/registerPrefetchOnAppStart/index.web.ts b/src/libs/Prefetch/registerPrefetchOnAppStart/index.web.ts new file mode 100644 index 000000000000..273beecee833 --- /dev/null +++ b/src/libs/Prefetch/registerPrefetchOnAppStart/index.web.ts @@ -0,0 +1,7 @@ +import type RegisterPrefetchOnAppStart from './types'; + +const NOOP: RegisterPrefetchOnAppStart = () => {}; + +const registerPrefetchOnAppStart: RegisterPrefetchOnAppStart = NOOP; + +export default registerPrefetchOnAppStart; diff --git a/src/libs/Prefetch/registerPrefetchOnAppStart/types.ts b/src/libs/Prefetch/registerPrefetchOnAppStart/types.ts new file mode 100644 index 000000000000..1e503b27115f --- /dev/null +++ b/src/libs/Prefetch/registerPrefetchOnAppStart/types.ts @@ -0,0 +1,10 @@ +type RegisterPrefetchOnAppStartParams = { + prefetchKey: string | undefined; + command: string | undefined; + url: string; + fetchParams: RequestInit; +}; + +type RegisterPrefetchOnAppStart = (params: RegisterPrefetchOnAppStartParams) => void; + +export default RegisterPrefetchOnAppStart; diff --git a/src/libs/Reauthentication.ts b/src/libs/Reauthentication.ts index 64f53c53bb66..86deedf89156 100644 --- a/src/libs/Reauthentication.ts +++ b/src/libs/Reauthentication.ts @@ -1,4 +1,3 @@ -import CONFIG from '@src/CONFIG'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Account} from '@src/types/onyx'; @@ -15,13 +14,14 @@ import {isConnectedAsDelegate, restoreDelegateSession} from './actions/Delegate' import clearShortLivedAuthState from './actions/Session/clearShortLivedAuthState'; import updateSessionAuthTokens from './actions/Session/updateSessionAuthTokens'; import redirectToSignIn from './actions/SignInRedirect'; +import {AUTHENTICATION_COMMAND} from './API/types'; import HttpsError from './Errors/HttpsError'; import {getAuthenticateErrorMessage, getErrorMessage} from './ErrorUtils'; import Log from './Log'; import {post} from './Network'; import {getCredentials, hasReadRequiredDataFromStorage, setAuthToken, setIsAuthenticating} from './Network/NetworkStore'; import requireParameters from './requireParameters'; -import {checkIfShouldUseNewPartnerName} from './SessionUtils'; +import {checkIfShouldUseNewPartnerName, getPartnerCredentials} from './SessionUtils'; import trackAuthenticationError from './telemetry/trackAuthenticationError'; type Parameters = { @@ -106,7 +106,7 @@ Onyx.connectWithoutView({ }); function Authenticate(parameters: Parameters): Promise | void> { - const commandName = 'Authenticate'; + const commandName = AUTHENTICATION_COMMAND; try { requireParameters(['partnerName', 'partnerPassword', 'partnerUserID', 'partnerUserSecret'], parameters, commandName); @@ -115,7 +115,7 @@ function Authenticate(parameters: Parameters): Promise { delegatedAccess: account?.delegatedAccess, }); const credentials = isDelegate ? stashedCredentials : getCredentials(); - const shouldUseNewPartnerName = checkIfShouldUseNewPartnerName(credentials?.autoGeneratedLogin); - - const partnerName = shouldUseNewPartnerName ? CONFIG.EXPENSIFY.PARTNER_NAME : CONFIG.EXPENSIFY.LEGACY_PARTNER_NAME; - const partnerPassword = shouldUseNewPartnerName ? CONFIG.EXPENSIFY.PARTNER_PASSWORD : CONFIG.EXPENSIFY.LEGACY_PARTNER_PASSWORD; + const {partnerName, partnerPassword} = getPartnerCredentials(credentials?.autoGeneratedLogin); // Supportal sessions authenticate with a short-lived support auth token and must never be sent through the // customer's SAML flow. Skipping the SAML redirect lets a support session fall through to the normal @@ -243,7 +240,7 @@ function reauthenticate(command = ''): Promise { return false; } - Log.info(`[Reauthenticate] Re-authenticating with ${shouldUseNewPartnerName ? 'new' : 'old'} partner name`); + Log.info(`[Reauthenticate] Re-authenticating with ${checkIfShouldUseNewPartnerName(credentials?.autoGeneratedLogin) ? 'new' : 'old'} partner name`); Log.hmmm('[Reauthenticate] Starting authentication process', { command, diff --git a/src/libs/Request.ts b/src/libs/Request.ts index 661740eef662..5268379d40c8 100644 --- a/src/libs/Request.ts +++ b/src/libs/Request.ts @@ -20,20 +20,24 @@ function makeXHR(request: Request): Promise(request: Request, isFromSequentialQueue = false): Promise | void> { - return middlewares - .reduce((last, middleware) => middleware(last, request, isFromSequentialQueue), makeXHR(request)) - .catch((reason: unknown) => { - // Real Errors are already normalized/classified by the Logging middleware; pass them through untouched. - if (reason instanceof Error) { - throw reason; - } - // A non-Error rejection (e.g. a bare `null` bubbling up from an outer data middleware above - // Logging) would otherwise surface as a stack-less, context-free onunhandledrejection (APP-5J). - // Wrap it so the next occurrence on any command carries command context and a stack. - const normalizedError = new Error(`[API] ${request.command} rejected: ${String(reason)}`); - Log.alert('[API] non-Error rejection surfaced from the request pipeline', {command: request.command, reason: String(reason)}); - throw normalizedError; - }); + let result = makeXHR(request); + + for (const middleware of middlewares) { + result = middleware(result, request, isFromSequentialQueue); + } + + return result.catch((reason: unknown) => { + // Real Errors are already normalized/classified by the Logging middleware; pass them through untouched. + if (reason instanceof Error) { + throw reason; + } + // A non-Error rejection (e.g. a bare `null` bubbling up from an outer data middleware above + // Logging) would otherwise surface as a stack-less, context-free onunhandledrejection (APP-5J). + // Wrap it so the next occurrence on any command carries command context and a stack. + const normalizedError = new Error(`[API] ${request.command} rejected: ${String(reason)}`); + Log.alert('[API] non-Error rejection surfaced from the request pipeline', {command: request.command, reason: String(reason)}); + throw normalizedError; + }); } function addMiddleware(middleware: Middleware) { diff --git a/src/libs/SessionUtils.ts b/src/libs/SessionUtils.ts index 4700bae5228b..8f362ab48aed 100644 --- a/src/libs/SessionUtils.ts +++ b/src/libs/SessionUtils.ts @@ -110,6 +110,15 @@ function checkIfShouldUseNewPartnerName(partnerUserID?: string): boolean { return false; } +function getPartnerCredentials(autoGeneratedLogin?: string) { + const useNewPartnerName = checkIfShouldUseNewPartnerName(autoGeneratedLogin); + + return { + partnerName: useNewPartnerName ? CONFIG.EXPENSIFY.PARTNER_NAME : CONFIG.EXPENSIFY.LEGACY_PARTNER_NAME, + partnerPassword: useNewPartnerName ? CONFIG.EXPENSIFY.PARTNER_PASSWORD : CONFIG.EXPENSIFY.LEGACY_PARTNER_PASSWORD, + }; +} + const AGENT_EMAIL_REGEX = /^agent_\d+@expensify\.ai$/i; function isAgentEmail(email?: string): boolean { @@ -124,4 +133,13 @@ function useIsAgentAccount(): boolean { return isAgentEmail(sessionEmail); } -export {isLoggingInAsNewUser, didUserLogInDuringSession, resetDidUserLogInDuringSession, checkIfShouldUseNewPartnerName, isLoggingInAsDelegate, isAgentEmail, useIsAgentAccount}; +export { + isLoggingInAsNewUser, + didUserLogInDuringSession, + resetDidUserLogInDuringSession, + checkIfShouldUseNewPartnerName, + getPartnerCredentials, + isLoggingInAsDelegate, + isAgentEmail, + useIsAgentAccount, +}; diff --git a/src/libs/actions/Session/index.ts b/src/libs/actions/Session/index.ts index 7b73c75c0340..4749be794c50 100644 --- a/src/libs/actions/Session/index.ts +++ b/src/libs/actions/Session/index.ts @@ -34,12 +34,13 @@ import * as MainQueue from '@libs/Network/MainQueue'; import * as NetworkStore from '@libs/Network/NetworkStore'; import {getCurrentUserEmail} from '@libs/Network/NetworkStore'; import * as SequentialQueue from '@libs/Network/SequentialQueue'; +import clearPrefetchOnAppStart from '@libs/Prefetch/clearPrefetchOnAppStart'; import Pusher from '@libs/Pusher'; import reauthenticate from '@libs/Reauthentication'; import {getReportIDFromLink} from '@libs/ReportUtils'; import {runSessionCleanupCallbacks} from '@libs/SessionCleanup'; import * as SessionUtils from '@libs/SessionUtils'; -import {checkIfShouldUseNewPartnerName, resetDidUserLogInDuringSession} from '@libs/SessionUtils'; +import {getPartnerCredentials, resetDidUserLogInDuringSession} from '@libs/SessionUtils'; import {clearSoundAssetsCache} from '@libs/Sound'; import {logReceiptQueueSnapshot} from '@libs/telemetry/ReceiptObservability'; import Timers from '@libs/Timers'; @@ -263,14 +264,18 @@ function signInWithSupportAuthToken(authToken: string) { function signOut(params: {autoGeneratedLogin?: string; signedInWithSAML?: boolean; authToken?: string} = {}): Promise> { Log.info('Flushing logs before signing out', true, {}, true); logReceiptQueueSnapshot('signOut'); - const shouldUseNewPartnerName = checkIfShouldUseNewPartnerName(params.autoGeneratedLogin); + const {partnerName, partnerPassword} = getPartnerCredentials(params.autoGeneratedLogin); + + // When the user signs out, clear native startup prefetch state so the next cold start cannot replay + // a queued ReconnectApp request built with the previous session's auth token. + clearPrefetchOnAppStart(); const logOutParams: LogOutParams = { // Send current authToken because we will immediately clear it once triggering this command authToken: params.authToken ?? null, partnerUserID: params.autoGeneratedLogin ?? '', - partnerName: shouldUseNewPartnerName ? CONFIG.EXPENSIFY.PARTNER_NAME : CONFIG.EXPENSIFY.LEGACY_PARTNER_NAME, - partnerPassword: shouldUseNewPartnerName ? CONFIG.EXPENSIFY.PARTNER_PASSWORD : CONFIG.EXPENSIFY.LEGACY_PARTNER_PASSWORD, + partnerName, + partnerPassword, shouldRetry: false, skipReauthentication: true, }; diff --git a/src/libs/actions/Session/updateSessionAuthTokens.ts b/src/libs/actions/Session/updateSessionAuthTokens.ts index e76cbe77ce23..927edb399846 100644 --- a/src/libs/actions/Session/updateSessionAuthTokens.ts +++ b/src/libs/actions/Session/updateSessionAuthTokens.ts @@ -1,7 +1,13 @@ +import clearPrefetchOnAppStart from '@libs/Prefetch/clearPrefetchOnAppStart'; + import ONYXKEYS from '@src/ONYXKEYS'; import Onyx from 'react-native-onyx'; export default function updateSessionAuthTokens(authToken?: string, encryptedAuthToken?: string) { + // Startup prefetches are persisted natively across launches. Drop any queue/token-refresh + // config tied to the previous auth token before saving the replacement. + clearPrefetchOnAppStart(); + return Onyx.merge(ONYXKEYS.SESSION, {authToken, encryptedAuthToken, creationDate: new Date().getTime()}); } diff --git a/src/libs/actions/clearOnyxAndSeedFullReconnect.ts b/src/libs/actions/clearOnyxAndSeedFullReconnect.ts index 8b2a0e8b9d81..d9b998aba73d 100644 --- a/src/libs/actions/clearOnyxAndSeedFullReconnect.ts +++ b/src/libs/actions/clearOnyxAndSeedFullReconnect.ts @@ -1,4 +1,5 @@ import DateUtils from '@libs/DateUtils'; +import clearPrefetchOnAppStart from '@libs/Prefetch/clearPrefetchOnAppStart'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -19,6 +20,10 @@ import Onyx from 'react-native-onyx'; * preserve list automatically so they survive the clear. */ function clearOnyxAndSeedFullReconnect(keysToPreserve: OnyxKey[], extraSeeds?: OnyxMultiSetInput): Promise { + // Any Onyx reset can preserve or replace SESSION/CREDENTIALS while dropping account-scoped data. + // Clear native startup prefetches so a cold start cannot replay requests from the previous identity. + clearPrefetchOnAppStart(); + const seeds: OnyxMultiSetInput = { ...extraSeeds, [ONYXKEYS.LAST_FULL_RECONNECT_TIME]: DateUtils.getDBTime(), diff --git a/src/libs/telemetry/trackAuthenticationError.ts b/src/libs/telemetry/trackAuthenticationError.ts index ec3e09ff1c24..0ec4a1da8ee5 100644 --- a/src/libs/telemetry/trackAuthenticationError.ts +++ b/src/libs/telemetry/trackAuthenticationError.ts @@ -1,8 +1,10 @@ +import type {AUTHENTICATION_COMMAND} from '@libs/API/types'; + import CONST from '@src/CONST'; import * as Sentry from '@sentry/react-native'; -type AuthenticationFunction = 'Authenticate' | 'reauthenticate'; +type AuthenticationFunction = typeof AUTHENTICATION_COMMAND | 'reauthenticate'; type AuthenticationErrorType = 'missing_params' | 'network_retry' | 'auth_failure' | 'unexpected_error'; type AuthenticationErrorContext = { diff --git a/src/polyfills/NitroFetch.ts b/src/polyfills/NitroFetch.ts new file mode 100644 index 000000000000..62c3c4c30f3a --- /dev/null +++ b/src/polyfills/NitroFetch.ts @@ -0,0 +1,10 @@ +/** + * Use Nitro's fetch implementation for every bare `fetch(...)` on native. + * On web, NitroFetch still uses the vanilla global fetch implementation. + */ +import {fetch as nitroFetch, Headers as NitroHeaders, Request as NitroRequest, Response as NitroResponse} from 'react-native-nitro-fetch'; + +globalThis.fetch = nitroFetch; +globalThis.Headers = NitroHeaders; +globalThis.Request = NitroRequest; +globalThis.Response = NitroResponse; diff --git a/src/polyfills/NitroFetch.web.ts b/src/polyfills/NitroFetch.web.ts new file mode 100644 index 000000000000..de04469c65ee --- /dev/null +++ b/src/polyfills/NitroFetch.web.ts @@ -0,0 +1 @@ +// On web, we don't use NitroFetch, so no polyfill needed diff --git a/src/types/onyx/Session.ts b/src/types/onyx/Session.ts index 93b310b50a9c..5b687b7d2b08 100644 --- a/src/types/onyx/Session.ts +++ b/src/types/onyx/Session.ts @@ -13,7 +13,7 @@ type Session = { email?: string; /** Currently logged in user authToken */ - authToken?: string; + [CONST.HTTP_HEADER_NAMES.AUTH_TOKEN]?: string; /** Currently logged in user authToken type */ authTokenType?: ValueOf; diff --git a/tests/actions/SessionTest.ts b/tests/actions/SessionTest.ts index 18eb30329aa2..edb1808ef29f 100644 --- a/tests/actions/SessionTest.ts +++ b/tests/actions/SessionTest.ts @@ -1,3 +1,4 @@ +/* eslint-disable rulesdir/no-multiple-api-calls */ // cspell:ignore SOMESECRETKEY import {beforeEach, jest, test} from '@jest/globals'; @@ -29,6 +30,7 @@ import type {Credentials, Session} from '@src/types/onyx'; import type {OnyxEntry} from 'react-native-onyx'; import {openAuthSessionAsync} from 'expo-web-browser'; +import {clearTokenRefresh, removeFromAutoPrefetch} from 'react-native-nitro-fetch'; import Onyx from 'react-native-onyx'; import * as TestHelper from '../utils/TestHelper'; @@ -453,6 +455,36 @@ describe('Session', () => { expect(getAllPersistedRequests().length).toBe(0); }); + test('SignOut should clear native startup prefetch state', async () => { + await TestHelper.signInWithTestUser(); + setHasRadio(false); + await waitForBatchedUpdates(); + + await SessionUtil.signOut({authToken: 'testAuthToken'}); + + expect(clearTokenRefresh).toHaveBeenCalledWith('fetch'); + expect(removeFromAutoPrefetch).toHaveBeenCalledWith(WRITE_COMMANDS.RECONNECT_APP); + + setHasRadio(true); + await waitForBatchedUpdates(); + }); + + test('SignOut should clear native startup prefetch state before LOG_OUT', async () => { + const clearTokenRefreshMock = jest.mocked(clearTokenRefresh); + const removeFromAutoPrefetchMock = jest.mocked(removeFromAutoPrefetch); + const makeRequestSpy = jest.spyOn(API, 'makeRequestWithSideEffects').mockResolvedValue(undefined); + + await SessionUtil.signOut({authToken: 'testAuthToken'}); + + expect(clearTokenRefreshMock).toHaveBeenCalledWith('fetch'); + expect(removeFromAutoPrefetchMock).toHaveBeenCalledWith(WRITE_COMMANDS.RECONNECT_APP); + expect(makeRequestSpy).toHaveBeenCalledWith(SIDE_EFFECT_REQUEST_COMMANDS.LOG_OUT, expect.objectContaining({authToken: 'testAuthToken'}), {}); + expect(clearTokenRefreshMock.mock.invocationCallOrder.at(0)).toBeLessThan(makeRequestSpy.mock.invocationCallOrder.at(0) ?? 0); + expect(removeFromAutoPrefetchMock.mock.invocationCallOrder.at(0)).toBeLessThan(makeRequestSpy.mock.invocationCallOrder.at(0) ?? 0); + + makeRequestSpy.mockRestore(); + }); + describe('SignOutAndRedirectToSignIn', () => { test('SignOutAndRedirectToSignIn should redirect to OldDot when LogOut returns truthy hasOldDotAuthCookies', async () => { await TestHelper.signInWithTestUser(); @@ -659,7 +691,7 @@ describe('Session', () => { await waitForBatchedUpdates(); mockedOpenAuthSessionAsync.mockClear(); - // eslint-disable-next-line rulesdir/no-multiple-api-calls + const makeRequestSpy = jest.spyOn(API, 'makeRequestWithSideEffects').mockResolvedValue(undefined); mockedGetPlatform.mockReturnValue(CONST.PLATFORM.ANDROID); @@ -677,7 +709,7 @@ describe('Session', () => { await waitForBatchedUpdates(); mockedOpenAuthSessionAsync.mockImplementationOnce(() => Promise.reject(new Error('Browser session failed'))); - // eslint-disable-next-line rulesdir/no-multiple-api-calls + const makeRequestSpy = jest.spyOn(API, 'makeRequestWithSideEffects').mockResolvedValue(undefined); await SessionUtil.signOut({signedInWithSAML: true, authToken: 'testAuthToken', autoGeneratedLogin: 'testLogin'}); @@ -692,7 +724,7 @@ describe('Session', () => { await waitForBatchedUpdates(); mockedOpenAuthSessionAsync.mockClear(); - // eslint-disable-next-line rulesdir/no-multiple-api-calls + const makeRequestSpy = jest.spyOn(API, 'makeRequestWithSideEffects').mockResolvedValue(undefined); await SessionUtil.signOut({authToken: 'testAuthToken', autoGeneratedLogin: 'testLogin'}); @@ -706,7 +738,6 @@ describe('Session', () => { describe('replaceTwoFactorDevice', () => { test('sets isLoading and clears errors optimistically for both steps', () => { - // eslint-disable-next-line rulesdir/no-multiple-api-calls const writeSpy = jest.spyOn(API, 'write').mockResolvedValue(undefined); SessionUtil.replaceTwoFactorDevice('verify_old', '123456'); @@ -719,7 +750,6 @@ describe('Session', () => { }); test('verify_old success data does not clear twoFactorAuthSecretKey', () => { - // eslint-disable-next-line rulesdir/no-multiple-api-calls const writeSpy = jest.spyOn(API, 'write').mockResolvedValue(undefined); SessionUtil.replaceTwoFactorDevice('verify_old', '123456'); @@ -732,7 +762,6 @@ describe('Session', () => { }); test('verify_new success data clears twoFactorAuthSecretKey to signal step completion', () => { - // eslint-disable-next-line rulesdir/no-multiple-api-calls const writeSpy = jest.spyOn(API, 'write').mockResolvedValue(undefined); SessionUtil.replaceTwoFactorDevice('verify_new', '654321'); diff --git a/tests/unit/APITest.ts b/tests/unit/APITest.ts index 52a2215f194c..5dc409c09d07 100644 --- a/tests/unit/APITest.ts +++ b/tests/unit/APITest.ts @@ -1,5 +1,6 @@ import {initReconnect} from '@libs/actions/Reconnect'; import type {EnablePolicyFeatureCommand} from '@libs/actions/RequestConflictUtils'; +import {AUTHENTICATION_COMMAND} from '@libs/API/types'; import type {ApiRequestCommandParameters, ReadCommand, WriteCommand} from '@libs/API/types'; import CONST from '@src/CONST'; @@ -340,7 +341,7 @@ describe('APITests', () => { const [commandName2] = call2; const [commandName3] = call3; expect(commandName1).toBe('Mock'); - expect(commandName2).toBe('Authenticate'); + expect(commandName2).toBe(AUTHENTICATION_COMMAND); expect(commandName3).toBe('Mock'); }) ); @@ -491,7 +492,7 @@ describe('APITests', () => { // Third command should be the call to Authenticate const [thirdCommand] = xhr.mock.calls.at(2) ?? []; - expect(thirdCommand).toBe('Authenticate'); + expect(thirdCommand).toBe(AUTHENTICATION_COMMAND); const [fourthCommand] = xhr.mock.calls.at(3) ?? []; expect(fourthCommand).toBe('MockCommand'); diff --git a/tests/unit/AuthPrefetchTest.ts b/tests/unit/AuthPrefetchTest.ts new file mode 100644 index 000000000000..dd7e9a0464e1 --- /dev/null +++ b/tests/unit/AuthPrefetchTest.ts @@ -0,0 +1,61 @@ +import ONYXKEYS from '@src/ONYXKEYS'; + +const mockClearPrefetchOnAppStart = jest.fn(); +const mockMerge = jest.fn(); +const mockMultiSet = jest.fn(); +const mockClear = jest.fn(); +const mockGetDBTime = jest.fn(); + +jest.mock('@libs/Prefetch/clearPrefetchOnAppStart', () => mockClearPrefetchOnAppStart); +jest.mock('@libs/DateUtils', () => ({ + getDBTime: mockGetDBTime, +})); +jest.mock('react-native-onyx', () => ({ + __esModule: true, + default: { + merge: mockMerge, + multiSet: mockMultiSet, + clear: mockClear, + }, +})); + +describe('auth startup prefetch cleanup', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockMerge.mockResolvedValue(undefined); + mockMultiSet.mockResolvedValue(undefined); + mockClear.mockResolvedValue(undefined); + mockGetDBTime.mockReturnValue('2026-07-16 12:00:00.000'); + }); + + it('clears native startup prefetches before replacing session auth tokens', async () => { + const creationDate = 123456789; + const getTimeSpy = jest.spyOn(Date.prototype, 'getTime').mockReturnValue(creationDate); + const updateSessionAuthTokens = (await import('@userActions/Session/updateSessionAuthTokens')).default; + + await updateSessionAuthTokens('authToken', 'encryptedAuthToken'); + + expect(mockClearPrefetchOnAppStart).toHaveBeenCalledTimes(1); + expect(mockMerge).toHaveBeenCalledWith(ONYXKEYS.SESSION, { + authToken: 'authToken', + encryptedAuthToken: 'encryptedAuthToken', + creationDate, + }); + expect(mockClearPrefetchOnAppStart.mock.invocationCallOrder.at(0)).toBeLessThan(mockMerge.mock.invocationCallOrder.at(0) ?? 0); + getTimeSpy.mockRestore(); + }); + + it('clears native startup prefetches before resetting Onyx for an identity transition', async () => { + const clearOnyxAndSeedFullReconnect = (await import('@userActions/clearOnyxAndSeedFullReconnect')).default; + + await clearOnyxAndSeedFullReconnect([ONYXKEYS.SESSION], {[ONYXKEYS.IS_LOADING_APP]: true}); + + expect(mockClearPrefetchOnAppStart).toHaveBeenCalledTimes(1); + expect(mockMultiSet).toHaveBeenCalledWith({ + [ONYXKEYS.IS_LOADING_APP]: true, + [ONYXKEYS.LAST_FULL_RECONNECT_TIME]: '2026-07-16 12:00:00.000', + }); + expect(mockClear).toHaveBeenCalledWith([ONYXKEYS.SESSION, ONYXKEYS.IS_LOADING_APP, ONYXKEYS.LAST_FULL_RECONNECT_TIME]); + expect(mockClearPrefetchOnAppStart.mock.invocationCallOrder.at(0)).toBeLessThan(mockMultiSet.mock.invocationCallOrder.at(0) ?? 0); + }); +}); diff --git a/tests/unit/NetworkTest.tsx b/tests/unit/NetworkTest.tsx index 0a779dfef6e4..38762c2ffeb9 100644 --- a/tests/unit/NetworkTest.tsx +++ b/tests/unit/NetworkTest.tsx @@ -1,5 +1,6 @@ import {reconnectApp} from '@libs/actions/App'; import * as Reconnect from '@libs/actions/Reconnect'; +import {AUTHENTICATION_COMMAND} from '@libs/API/types'; import {reset as resetFailureTracker} from '@libs/FailureTracker'; import {resetReauthentication} from '@libs/Middleware/Reauthentication'; @@ -119,7 +120,7 @@ describe('NetworkTests', () => { // Verify: // 1. We attempted to authenticate twice (first failed, retry succeeded) // 2. The session has the new auth token (user wasn't logged out) - const callsToAuthenticate = (HttpUtils.xhr as jest.Mock).mock.calls.filter(([command]) => command === 'Authenticate'); + const callsToAuthenticate = (HttpUtils.xhr as jest.Mock).mock.calls.filter(([command]) => command === AUTHENTICATION_COMMAND); expect(callsToAuthenticate.length).toBe(2); expect(sessionState?.authToken).toBe(NEW_AUTH_TOKEN); }); @@ -182,7 +183,7 @@ describe('NetworkTests', () => { // 5. Authentication Start - Verify authenticate was triggered await waitForBatchedUpdates(); const secondCall = mockedXhr.mock.calls.at(1) as [string, Record]; - expect(secondCall[0]).toBe('Authenticate'); + expect(secondCall[0]).toBe(AUTHENTICATION_COMMAND); // 6. Network State Change - Set offline and back online while authenticate is pending setHasRadio(false); @@ -247,7 +248,7 @@ describe('NetworkTests', () => { .then(() => { // Verify: 3 calls to the API, 1 authenticate call, and reconnect was triggered const callsToOpenPublicProfilePage = (HttpUtils.xhr as jest.Mock).mock.calls.filter(([command]) => command === 'OpenPublicProfilePage'); - const callsToAuthenticate = (HttpUtils.xhr as jest.Mock).mock.calls.filter(([command]) => command === 'Authenticate'); + const callsToAuthenticate = (HttpUtils.xhr as jest.Mock).mock.calls.filter(([command]) => command === AUTHENTICATION_COMMAND); expect(callsToOpenPublicProfilePage.length).toBe(3); expect(callsToAuthenticate.length).toBe(1); expect(reconnectSpy).toHaveBeenCalled(); diff --git a/tests/unit/SessionUtilsTest.ts b/tests/unit/SessionUtilsTest.ts index 3dae53dbebf4..c1d71e5bcbe8 100644 --- a/tests/unit/SessionUtilsTest.ts +++ b/tests/unit/SessionUtilsTest.ts @@ -1,4 +1,4 @@ -import {checkIfShouldUseNewPartnerName, isAgentEmail, isLoggingInAsDelegate} from '@src/libs/SessionUtils'; +import {checkIfShouldUseNewPartnerName, getPartnerCredentials, isAgentEmail, isLoggingInAsDelegate} from '@src/libs/SessionUtils'; function mockHybridAppConfig(isHybridApp: boolean): () => void { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment @@ -69,6 +69,30 @@ describe('SessionUtils', () => { }); }); + describe('getPartnerCredentials', () => { + test.each([ + ['should return new partner credentials when not in HybridApp', false, 'any-user-id'], + ['should return new partner credentials for expensify.cash- prefix when in HybridApp', true, 'expensify.cash-12345'], + ['should return legacy partner credentials for legacy partnerUserID when in HybridApp', true, 'legacy-user-12345'], + ])('%s', (_description, isHybridApp, partnerUserID) => { + const cleanup = mockHybridAppConfig(isHybridApp); + + try { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const CONFIG = require('@src/CONFIG'); + const useNewPartnerName = checkIfShouldUseNewPartnerName(partnerUserID); + const {partnerName, partnerPassword} = getPartnerCredentials(partnerUserID); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + expect(partnerName).toBe(useNewPartnerName ? CONFIG.default.EXPENSIFY.PARTNER_NAME : CONFIG.default.EXPENSIFY.LEGACY_PARTNER_NAME); + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + expect(partnerPassword).toBe(useNewPartnerName ? CONFIG.default.EXPENSIFY.PARTNER_PASSWORD : CONFIG.default.EXPENSIFY.LEGACY_PARTNER_PASSWORD); + } finally { + cleanup(); + } + }); + }); + describe('isLoggingInAsDelegate', () => { test.each([ ['should return false when transitionURL is undefined', undefined, false], From 1f6902a5c603d6bf58d578f577ddfb7b5c7dd4fe Mon Sep 17 00:00:00 2001 From: chrispader Date: Mon, 27 Jul 2026 13:32:46 +0200 Subject: [PATCH 02/14] chore(deps): bump nitro fetch --- package-lock.json | 29 ++++++++++++++++++++--------- package.json | 4 ++-- 2 files changed, 22 insertions(+), 11 deletions(-) diff --git a/package-lock.json b/package-lock.json index d333db04f5ce..3aa71c0415b1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -121,8 +121,8 @@ "react-native-key-command": "1.0.14", "react-native-keyboard-controller": "1.21.0-beta.1", "react-native-localize": "^3.5.4", - "react-native-nitro-fetch": "1.5.0", - "react-native-nitro-modules": "0.35.9", + "react-native-nitro-fetch": "1.5.4", + "react-native-nitro-modules": "0.36.1", "react-native-nitro-sqlite": "9.6.0", "react-native-onyx": "3.0.90", "react-native-pager-view": "8.0.0", @@ -33609,6 +33609,17 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/nitrogen/node_modules/react-native-nitro-modules": { + "version": "0.35.10", + "resolved": "https://registry.npmjs.org/react-native-nitro-modules/-/react-native-nitro-modules-0.35.10.tgz", + "integrity": "sha512-KsySOAIkbSTjNiX2GvXiNT9P5DMeZd99yvtE/+Lw7RXV7Ztxh9Z+YyAbYkBqadhN5J/KXXuPjhRYgyLOIZgsbQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, "node_modules/nitrogen/node_modules/zod": { "version": "4.1.12", "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.12.tgz", @@ -35830,9 +35841,9 @@ } }, "node_modules/react-native-nitro-fetch": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/react-native-nitro-fetch/-/react-native-nitro-fetch-1.5.0.tgz", - "integrity": "sha512-8HSiW0l5eVNhZuRkSprv9Z+OonhSOBkjeMnRvVshJuPXBQzghXidOyrVEO3HXEB+pruJT1OMblCjU7lMyQVD9w==", + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/react-native-nitro-fetch/-/react-native-nitro-fetch-1.5.4.tgz", + "integrity": "sha512-RhiFeXkhSHhc1Qlo4qNG10a0LqKtfkg/W07+922+nXVkoWQMeZ/iY88dWU8bqi90LvR8wInmbovrorlTF8Qdxg==", "license": "MIT", "workspaces": [ "example" @@ -35843,7 +35854,7 @@ "peerDependencies": { "react": "*", "react-native": "*", - "react-native-nitro-modules": "^0.35.2", + "react-native-nitro-modules": "^0.36.1", "react-native-worklets": ">=0.8.0" }, "peerDependenciesMeta": { @@ -35867,9 +35878,9 @@ } }, "node_modules/react-native-nitro-modules": { - "version": "0.35.9", - "resolved": "https://registry.npmjs.org/react-native-nitro-modules/-/react-native-nitro-modules-0.35.9.tgz", - "integrity": "sha512-yCO6eJ85SPPUo4a4an7H5oj6wPCSIT72fbjr5WZ/20n6zswaJ2gNNpnWtg2We0AZwkAOjSqkOJ0Vjc05p6kGiA==", + "version": "0.36.1", + "resolved": "https://registry.npmjs.org/react-native-nitro-modules/-/react-native-nitro-modules-0.36.1.tgz", + "integrity": "sha512-kBv/VvKqAmkXAvP1DxJMC9b/fRhh7JdSO4EUnPP46hJjrIFeFR8AwKm8mYaKZEuF014M/TVdv2vomVUW0umsQQ==", "license": "MIT", "peerDependencies": { "react": "*", diff --git a/package.json b/package.json index 814a65ab4f68..0a0d20d0b715 100644 --- a/package.json +++ b/package.json @@ -195,8 +195,8 @@ "react-native-key-command": "1.0.14", "react-native-keyboard-controller": "1.21.0-beta.1", "react-native-localize": "^3.5.4", - "react-native-nitro-fetch": "1.5.0", - "react-native-nitro-modules": "0.35.9", + "react-native-nitro-fetch": "1.5.4", + "react-native-nitro-modules": "0.36.1", "react-native-nitro-sqlite": "9.6.0", "react-native-onyx": "3.0.90", "react-native-pager-view": "8.0.0", From db5dc931f1fa171d3b292b80ad831dd2e87ee348 Mon Sep 17 00:00:00 2001 From: chrispader Date: Mon, 27 Jul 2026 13:37:19 +0200 Subject: [PATCH 03/14] build: update `Podfile.lock` --- ios/Podfile.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 80a1da3d9244..9ec67daa5c75 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -410,7 +410,7 @@ PODS: - nanopb/encode (= 3.30910.0) - nanopb/decode (3.30910.0) - nanopb/encode (3.30910.0) - - NitroFetch (1.5.0): + - NitroFetch (1.5.4): - boost - DoubleConversion - fast_float @@ -440,7 +440,7 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga - - NitroModules (0.35.9): + - NitroModules (0.36.1): - boost - DoubleConversion - fast_float @@ -4897,8 +4897,8 @@ SPEC CHECKSUMS: MapboxCoreMaps: de1a4461d0ab5d65d907f06e8d82fdbe06bb3213 MapboxMaps: 22340ae0e0d47b2e4761019b1d6c569ab04e088d nanopb: fad817b59e0457d11a5dfbde799381cd727c1275 - NitroFetch: e3ed4327927d6462de3b7cb3afff273fc4907867 - NitroModules: d2d7b8a417d5f498ef5affa78d82113ad8c35c59 + NitroFetch: 4941ca1e41fc72ebdad33c50c85fee59a3dd6871 + NitroModules: 8b9fffe988a1ff6d0e9409db52035837a25de35d NWWebSocket: b4741420f1976e1dff4da3edad00c401e4f1d769 Onfido: 65454f91d10758193c857fd149417f6efbea84c5 onfido-react-native-sdk: bb8cfd9198e2e97978461d969497d18b37e45ca7 From 7989348d17ac5198711bfadf42d427b426072613 Mon Sep 17 00:00:00 2001 From: chrispader Date: Mon, 27 Jul 2026 13:43:18 +0200 Subject: [PATCH 04/14] fix: make `clearPrefetchOnAppStart` async and await result before signing out etc. --- src/libs/Prefetch/clearPrefetchOnAppStart/index.ts | 14 ++++++++------ .../Prefetch/clearPrefetchOnAppStart/index.web.ts | 2 +- src/libs/Prefetch/clearPrefetchOnAppStart/types.ts | 2 +- src/libs/actions/Session/index.ts | 4 ++-- .../actions/Session/updateSessionAuthTokens.ts | 4 ++-- src/libs/actions/clearOnyxAndSeedFullReconnect.ts | 4 ++-- 6 files changed, 16 insertions(+), 14 deletions(-) diff --git a/src/libs/Prefetch/clearPrefetchOnAppStart/index.ts b/src/libs/Prefetch/clearPrefetchOnAppStart/index.ts index bf9d53d5c758..fcb78b93995e 100644 --- a/src/libs/Prefetch/clearPrefetchOnAppStart/index.ts +++ b/src/libs/Prefetch/clearPrefetchOnAppStart/index.ts @@ -5,14 +5,16 @@ import {clearTokenRefresh, removeFromAutoPrefetch} from 'react-native-nitro-fetc import type ClearPrefetchOnAppStart from './types'; -const clearPrefetchOnAppStart: ClearPrefetchOnAppStart = () => { +const clearPrefetchOnAppStart: ClearPrefetchOnAppStart = async () => { clearTokenRefresh('fetch'); - for (const command of PrefetchQueries) { - removeFromAutoPrefetch(command).catch((error) => { - Log.warn(`[HttpUtils] removeFromAutoPrefetch failed for ${command}`, {error}); - }); - } + await Promise.all( + Array.from(PrefetchQueries).map(async (command) => { + await removeFromAutoPrefetch(command).catch((error) => { + Log.warn(`[HttpUtils] removeFromAutoPrefetch failed for ${command}`, {error}); + }); + }), + ); }; export default clearPrefetchOnAppStart; diff --git a/src/libs/Prefetch/clearPrefetchOnAppStart/index.web.ts b/src/libs/Prefetch/clearPrefetchOnAppStart/index.web.ts index 15bd103ee1e4..541f01529a1a 100644 --- a/src/libs/Prefetch/clearPrefetchOnAppStart/index.web.ts +++ b/src/libs/Prefetch/clearPrefetchOnAppStart/index.web.ts @@ -1,5 +1,5 @@ import type ClearPrefetchOnAppStart from './types'; -const clearPrefetchOnAppStart: ClearPrefetchOnAppStart = () => {}; +const clearPrefetchOnAppStart: ClearPrefetchOnAppStart = () => Promise.resolve(); export default clearPrefetchOnAppStart; diff --git a/src/libs/Prefetch/clearPrefetchOnAppStart/types.ts b/src/libs/Prefetch/clearPrefetchOnAppStart/types.ts index 567735ad2adc..1c13e912db4e 100644 --- a/src/libs/Prefetch/clearPrefetchOnAppStart/types.ts +++ b/src/libs/Prefetch/clearPrefetchOnAppStart/types.ts @@ -1,3 +1,3 @@ -type ClearPrefetchOnAppStart = () => void; +type ClearPrefetchOnAppStart = () => Promise; export default ClearPrefetchOnAppStart; diff --git a/src/libs/actions/Session/index.ts b/src/libs/actions/Session/index.ts index 4749be794c50..694e01c1148f 100644 --- a/src/libs/actions/Session/index.ts +++ b/src/libs/actions/Session/index.ts @@ -261,14 +261,14 @@ function signInWithSupportAuthToken(authToken: string) { /** * Calls the LOG_OUT API to invalidate the session on the server. */ -function signOut(params: {autoGeneratedLogin?: string; signedInWithSAML?: boolean; authToken?: string} = {}): Promise> { +async function signOut(params: {autoGeneratedLogin?: string; signedInWithSAML?: boolean; authToken?: string} = {}): Promise> { Log.info('Flushing logs before signing out', true, {}, true); logReceiptQueueSnapshot('signOut'); const {partnerName, partnerPassword} = getPartnerCredentials(params.autoGeneratedLogin); // When the user signs out, clear native startup prefetch state so the next cold start cannot replay // a queued ReconnectApp request built with the previous session's auth token. - clearPrefetchOnAppStart(); + await clearPrefetchOnAppStart(); const logOutParams: LogOutParams = { // Send current authToken because we will immediately clear it once triggering this command diff --git a/src/libs/actions/Session/updateSessionAuthTokens.ts b/src/libs/actions/Session/updateSessionAuthTokens.ts index 927edb399846..eb116807b392 100644 --- a/src/libs/actions/Session/updateSessionAuthTokens.ts +++ b/src/libs/actions/Session/updateSessionAuthTokens.ts @@ -4,10 +4,10 @@ import ONYXKEYS from '@src/ONYXKEYS'; import Onyx from 'react-native-onyx'; -export default function updateSessionAuthTokens(authToken?: string, encryptedAuthToken?: string) { +export default async function updateSessionAuthTokens(authToken?: string, encryptedAuthToken?: string) { // Startup prefetches are persisted natively across launches. Drop any queue/token-refresh // config tied to the previous auth token before saving the replacement. - clearPrefetchOnAppStart(); + await clearPrefetchOnAppStart(); return Onyx.merge(ONYXKEYS.SESSION, {authToken, encryptedAuthToken, creationDate: new Date().getTime()}); } diff --git a/src/libs/actions/clearOnyxAndSeedFullReconnect.ts b/src/libs/actions/clearOnyxAndSeedFullReconnect.ts index d9b998aba73d..7e37860cd0e3 100644 --- a/src/libs/actions/clearOnyxAndSeedFullReconnect.ts +++ b/src/libs/actions/clearOnyxAndSeedFullReconnect.ts @@ -19,10 +19,10 @@ import Onyx from 'react-native-onyx'; * IS_LOADING_APP=true for delegate transitions). Seeded keys are appended to the * preserve list automatically so they survive the clear. */ -function clearOnyxAndSeedFullReconnect(keysToPreserve: OnyxKey[], extraSeeds?: OnyxMultiSetInput): Promise { +async function clearOnyxAndSeedFullReconnect(keysToPreserve: OnyxKey[], extraSeeds?: OnyxMultiSetInput): Promise { // Any Onyx reset can preserve or replace SESSION/CREDENTIALS while dropping account-scoped data. // Clear native startup prefetches so a cold start cannot replay requests from the previous identity. - clearPrefetchOnAppStart(); + await clearPrefetchOnAppStart(); const seeds: OnyxMultiSetInput = { ...extraSeeds, From 7ef09da8700e16d48166578a7349287f3ea33805 Mon Sep 17 00:00:00 2001 From: chrispader Date: Mon, 27 Jul 2026 15:54:20 +0200 Subject: [PATCH 05/14] fix: NitroFetch patch --- patches/details.md | 5 +- ...h => react-native-nitro-fetch+1.5.4.patch} | 113 ++++-------------- scripts/generateCertificatePins.sh | 2 +- 3 files changed, 27 insertions(+), 93 deletions(-) rename patches/{react-native-nitro-fetch+1.5.0.patch => react-native-nitro-fetch+1.5.4.patch} (70%) diff --git a/patches/details.md b/patches/details.md index b31e2d96203c..0792a2c47e60 100644 --- a/patches/details.md +++ b/patches/details.md @@ -1,14 +1,13 @@ # `react-native-nitro-fetch` patches -### [react-native-nitro-fetch+1.5.0.patch](react-native-nitro-fetch+1.5.0.patch) +### [react-native-nitro-fetch+1.5.4.patch](react-native-nitro-fetch+1.5.4.patch) - Reason: ``` NitroFetch bypasses the app's existing certificate-pinning clients on mobile. This patch applies Expensify's public-key pins to both Android Cronet engines, supports monitor-only reporting via - a separate pinned Cronet probe, enforces pins on real traffic when enabled, and gives every iOS - URLSession a delegate so TrustKit can validate standard requests, startup prefetches, token + a separate pinned Cronet probe, enforces pins on real traffic when enabled, validates standard requests, startup prefetches, token refreshes, and streaming requests. ``` diff --git a/patches/react-native-nitro-fetch+1.5.0.patch b/patches/react-native-nitro-fetch+1.5.4.patch similarity index 70% rename from patches/react-native-nitro-fetch+1.5.0.patch rename to patches/react-native-nitro-fetch+1.5.4.patch index ad913a331c72..43a46ce0e5dd 100644 --- a/patches/react-native-nitro-fetch+1.5.0.patch +++ b/patches/react-native-nitro-fetch+1.5.4.patch @@ -1,5 +1,5 @@ diff --git a/node_modules/react-native-nitro-fetch/android/build.gradle b/node_modules/react-native-nitro-fetch/android/build.gradle -index bd570da..eabe02a 100644 +index 45c0f78..2a3d84c 100644 --- a/node_modules/react-native-nitro-fetch/android/build.gradle +++ b/node_modules/react-native-nitro-fetch/android/build.gradle @@ -139,6 +139,7 @@ dependencies { @@ -197,11 +197,11 @@ index 0000000..a432432 + } + } +} -diff --git a/node_modules/react-native-nitro-fetch/android/src/main/java/com/margelo/nitro/nitrofetch/NitroCronet.kt b/node_modules/react-native-nitro-fetch/android/src/main/java/com/margelo/nitro/nitrofetch/NitroCronet.kt -index 3554aad..8e6fc4c 100644 ---- a/node_modules/react-native-nitro-fetch/android/src/main/java/com/margelo/nitro/nitrofetch/NitroCronet.kt -+++ b/node_modules/react-native-nitro-fetch/android/src/main/java/com/margelo/nitro/nitrofetch/NitroCronet.kt -@@ -64,6 +64,9 @@ class NitroCronet : HybridNitroCronetSpec() { +diff --git a/node_modules/react-native-nitro-fetch/android/src/main/java/com/margelo/nitro/nitrofetch/HybridNitroCronet.kt b/node_modules/react-native-nitro-fetch/android/src/main/java/com/margelo/nitro/nitrofetch/HybridNitroCronet.kt +index 49443e8..1d19f04 100644 +--- a/node_modules/react-native-nitro-fetch/android/src/main/java/com/margelo/nitro/nitrofetch/HybridNitroCronet.kt ++++ b/node_modules/react-native-nitro-fetch/android/src/main/java/com/margelo/nitro/nitrofetch/HybridNitroCronet.kt +@@ -64,6 +64,9 @@ class HybridNitroCronet : HybridNitroCronetSpec() { (50 * 1024 * 1024).toLong() ) @@ -211,11 +211,11 @@ index 3554aad..8e6fc4c 100644 val engine = builder.build() engineRef = engine return engine -diff --git a/node_modules/react-native-nitro-fetch/android/src/main/java/com/margelo/nitro/nitrofetch/NitroFetch.kt b/node_modules/react-native-nitro-fetch/android/src/main/java/com/margelo/nitro/nitrofetch/NitroFetch.kt -index ff872eb..ee1de2a 100644 ---- a/node_modules/react-native-nitro-fetch/android/src/main/java/com/margelo/nitro/nitrofetch/NitroFetch.kt -+++ b/node_modules/react-native-nitro-fetch/android/src/main/java/com/margelo/nitro/nitrofetch/NitroFetch.kt -@@ -60,6 +60,9 @@ class NitroFetch : HybridNitroFetchSpec() { +diff --git a/node_modules/react-native-nitro-fetch/android/src/main/java/com/margelo/nitro/nitrofetch/HybridNitroFetch.kt b/node_modules/react-native-nitro-fetch/android/src/main/java/com/margelo/nitro/nitrofetch/HybridNitroFetch.kt +index 1e5e0ff..55a83a6 100644 +--- a/node_modules/react-native-nitro-fetch/android/src/main/java/com/margelo/nitro/nitrofetch/HybridNitroFetch.kt ++++ b/node_modules/react-native-nitro-fetch/android/src/main/java/com/margelo/nitro/nitrofetch/HybridNitroFetch.kt +@@ -59,6 +59,9 @@ class HybridNitroFetch : HybridNitroFetchSpec() { // Prove DNS issues by mapping a host (TESTING ONLY, remove in prod): // builder.setExperimentalOptions("""{"HostResolverRules":{"host_resolver_rules":"MAP httpbin.org 54.167.17.38"}}""") @@ -223,13 +223,13 @@ index ff872eb..ee1de2a 100644 + ExpensifyCertificatePins.apply(builder, app) + val engine = builder.build() - Log.i("NitroFetch", "CronetEngine initialized. Provider=${nativeProvider?.name ?: "Default"} Cache=${cacheDir.absolutePath}") + NitroLogger.i("NitroFetch", "CronetEngine initialized. Provider=${nativeProvider?.name ?: "Default"} Cache=${cacheDir.absolutePath}") engineRef = engine -diff --git a/node_modules/react-native-nitro-fetch/android/src/main/java/com/margelo/nitro/nitrofetch/NitroFetchClient.kt b/node_modules/react-native-nitro-fetch/android/src/main/java/com/margelo/nitro/nitrofetch/NitroFetchClient.kt -index 308e465..9a59ee4 100644 ---- a/node_modules/react-native-nitro-fetch/android/src/main/java/com/margelo/nitro/nitrofetch/NitroFetchClient.kt -+++ b/node_modules/react-native-nitro-fetch/android/src/main/java/com/margelo/nitro/nitrofetch/NitroFetchClient.kt -@@ -107,6 +107,7 @@ class NitroFetchClient(private val engine: CronetEngine, private val executor: E +diff --git a/node_modules/react-native-nitro-fetch/android/src/main/java/com/margelo/nitro/nitrofetch/HybridNitroFetchClient.kt b/node_modules/react-native-nitro-fetch/android/src/main/java/com/margelo/nitro/nitrofetch/HybridNitroFetchClient.kt +index 283bd37..24f7c22 100644 +--- a/node_modules/react-native-nitro-fetch/android/src/main/java/com/margelo/nitro/nitrofetch/HybridNitroFetchClient.kt ++++ b/node_modules/react-native-nitro-fetch/android/src/main/java/com/margelo/nitro/nitrofetch/HybridNitroFetchClient.kt +@@ -107,6 +107,7 @@ class HybridNitroFetchClient(private val engine: CronetEngine, private val execu onFail: (Throwable) -> Unit ): UrlRequest { val url = req.url @@ -237,7 +237,7 @@ index 308e465..9a59ee4 100644 val shouldFollowRedirects = req.followRedirects ?: true val omitCredentials = req.credentials == NitroRequestCredentials.OMIT val traceLabel = if (BuildConfig.NITRO_FETCH_TRACING) { -@@ -250,6 +251,7 @@ class NitroFetchClient(private val engine: CronetEngine, private val executor: E +@@ -250,6 +251,7 @@ class HybridNitroFetchClient(private val engine: CronetEngine, private val execu } override fun onFailed(request: UrlRequest, info: UrlResponseInfo?, error: CronetException) { @@ -245,11 +245,11 @@ index 308e465..9a59ee4 100644 if (BuildConfig.NITRO_FETCH_TRACING) { Trace.endAsyncSection(traceLabel, traceCookie) } -diff --git a/node_modules/react-native-nitro-fetch/android/src/main/java/com/margelo/nitro/nitrofetch/NitroUrlRequestBuilder.kt b/node_modules/react-native-nitro-fetch/android/src/main/java/com/margelo/nitro/nitrofetch/NitroUrlRequestBuilder.kt -index 3937669..28c240b 100644 ---- a/node_modules/react-native-nitro-fetch/android/src/main/java/com/margelo/nitro/nitrofetch/NitroUrlRequestBuilder.kt -+++ b/node_modules/react-native-nitro-fetch/android/src/main/java/com/margelo/nitro/nitrofetch/NitroUrlRequestBuilder.kt -@@ -120,6 +120,7 @@ class NitroUrlRequestBuilder( +diff --git a/node_modules/react-native-nitro-fetch/android/src/main/java/com/margelo/nitro/nitrofetch/HybridUrlRequestBuilder.kt b/node_modules/react-native-nitro-fetch/android/src/main/java/com/margelo/nitro/nitrofetch/HybridUrlRequestBuilder.kt +index 48f2dc2..07806ba 100644 +--- a/node_modules/react-native-nitro-fetch/android/src/main/java/com/margelo/nitro/nitrofetch/HybridUrlRequestBuilder.kt ++++ b/node_modules/react-native-nitro-fetch/android/src/main/java/com/margelo/nitro/nitrofetch/HybridUrlRequestBuilder.kt +@@ -120,6 +120,7 @@ class HybridUrlRequestBuilder( info: CronetUrlResponseInfo?, error: CronetNativeException ) { @@ -257,7 +257,7 @@ index 3937669..28c240b 100644 if (devToolsEnabled) { DevToolsReporter.reportRequestFailed(devToolsRequestId, false) } -@@ -229,6 +230,7 @@ class NitroUrlRequestBuilder( +@@ -229,6 +230,7 @@ class HybridUrlRequestBuilder( } override fun build(): HybridUrlRequestSpec { @@ -265,68 +265,3 @@ index 3937669..28c240b 100644 val cronetRequest = builder.build() if (devToolsEnabled) { DevToolsReporter.reportRequestStart( -diff --git a/node_modules/react-native-nitro-fetch/ios/HybridNitroCronet.swift b/node_modules/react-native-nitro-fetch/ios/HybridNitroCronet.swift -index 344d1ef..fe11b64 100644 ---- a/node_modules/react-native-nitro-fetch/ios/HybridNitroCronet.swift -+++ b/node_modules/react-native-nitro-fetch/ios/HybridNitroCronet.swift -@@ -11,7 +11,7 @@ class HybridNitroCronet: HybridNitroCronetSpec { - diskCapacity: 100 * 1024 * 1024, - diskPath: "nitrofetch_urlcache" - ) -- return URLSession(configuration: config) -+ return ExpensifyPinnedURLSession.make(configuration: config) - }() - - // Shared executor queue -diff --git a/node_modules/react-native-nitro-fetch/ios/NitroAutoPrefetcher.swift b/node_modules/react-native-nitro-fetch/ios/NitroAutoPrefetcher.swift -index 84cf85c..606d595 100644 ---- a/node_modules/react-native-nitro-fetch/ios/NitroAutoPrefetcher.swift -+++ b/node_modules/react-native-nitro-fetch/ios/NitroAutoPrefetcher.swift -@@ -319,7 +319,7 @@ public final class NitroAutoPrefetcher: NSObject { - request.httpBody = body.data(using: .utf8) - } - -- let (data, response) = try await URLSession.shared.data(for: request) -+ let (data, response) = try await ExpensifyPinnedURLSession.shared.data(for: request) - guard let http = response as? HTTPURLResponse, - (200...299).contains(http.statusCode) else { - throw NSError(domain: "NitroAutoPrefetcher", code: -2, -diff --git a/node_modules/react-native-nitro-fetch/ios/NitroFetchClient.swift b/node_modules/react-native-nitro-fetch/ios/NitroFetchClient.swift -index 8e2c84b..f808f4c 100644 ---- a/node_modules/react-native-nitro-fetch/ios/NitroFetchClient.swift -+++ b/node_modules/react-native-nitro-fetch/ios/NitroFetchClient.swift -@@ -7,6 +7,25 @@ import UniformTypeIdentifiers - private let fetchLog = OSLog(subsystem: "com.margelo.nitrofetch", category: "network") - #endif - -+/** -+ * Expensify initializes TrustKit with URLSession delegate swizzling in release builds. TrustKit -+ * cannot observe sessions created without a delegate, so all NitroFetch sessions must use this -+ * delegate to keep certificate pinning active. -+ */ -+final class ExpensifyPinnedURLSessionDelegate: NSObject, URLSessionDelegate {} -+ -+enum ExpensifyPinnedURLSession { -+ static let shared = make(configuration: .default) -+ -+ static func make(configuration: URLSessionConfiguration) -> URLSession { -+ return URLSession( -+ configuration: configuration, -+ delegate: ExpensifyPinnedURLSessionDelegate(), -+ delegateQueue: nil -+ ) -+ } -+} -+ - final class NitroFetchClient: HybridNitroFetchClientSpec { - - private var _lock = os_unfair_lock() -@@ -101,7 +120,7 @@ final class NitroFetchClient: HybridNitroFetchClientSpec { - config.urlCache = URLCache(memoryCapacity: 32 * 1024 * 1024, - diskCapacity: 100 * 1024 * 1024, - diskPath: "nitrofetch_urlcache") -- return URLSession(configuration: config) -+ return ExpensifyPinnedURLSession.make(configuration: config) - }() - - private static func findPrefetchKey(_ req: NitroRequest) -> String? { diff --git a/scripts/generateCertificatePins.sh b/scripts/generateCertificatePins.sh index 8a5c63510112..ecb44caddfdd 100755 --- a/scripts/generateCertificatePins.sh +++ b/scripts/generateCertificatePins.sh @@ -21,7 +21,7 @@ # - android/app/src/main/res/xml/network_security_config_enforce.xml # - android/app/src/main/java/com/expensify/chat/CertificatePinning.kt # - ios/CertificatePinning.swift -# - patches/react-native-nitro-fetch+1.5.0.patch +# - patches/react-native-nitro-fetch+1.5.4.patch # - Mobile-Expensify/Android/res/xml/network_security_config_enforce.xml # - Mobile-Expensify/Android/src/yapl/android/http/ExpensifyCertificatePinner.java # - Mobile-Expensify/iOS/Expensify/ExpensifyAppDelegate.m (TrustKit kTSKPublicKeyHashes) From e4bd15ba59c1824e1d1240221a2a79ee19082584 Mon Sep 17 00:00:00 2001 From: chrispader Date: Tue, 28 Jul 2026 10:25:12 +0200 Subject: [PATCH 06/14] docs: add JSDocs for non-trivial modules --- src/libs/Prefetch/clearPrefetchOnAppStart/index.ts | 3 +++ src/libs/Prefetch/preparePrefetchRequest/index.ts | 3 +++ src/libs/Prefetch/registerPrefetchOnAppStart/index.ts | 4 ++++ 3 files changed, 10 insertions(+) diff --git a/src/libs/Prefetch/clearPrefetchOnAppStart/index.ts b/src/libs/Prefetch/clearPrefetchOnAppStart/index.ts index fcb78b93995e..bf8242bb3b73 100644 --- a/src/libs/Prefetch/clearPrefetchOnAppStart/index.ts +++ b/src/libs/Prefetch/clearPrefetchOnAppStart/index.ts @@ -1,3 +1,6 @@ +/** + * Clears the prefetch on app start. + */ import Log from '@libs/Log'; import PrefetchQueries from '@libs/Prefetch/PrefetchQueries'; diff --git a/src/libs/Prefetch/preparePrefetchRequest/index.ts b/src/libs/Prefetch/preparePrefetchRequest/index.ts index 4e3f478d938a..791525a47128 100644 --- a/src/libs/Prefetch/preparePrefetchRequest/index.ts +++ b/src/libs/Prefetch/preparePrefetchRequest/index.ts @@ -1,3 +1,6 @@ +/** + * Prepares a prefetch request for a given command. + */ import PrefetchQueries from '@libs/Prefetch/PrefetchQueries'; import type PreparePrefetchRequest from './types'; diff --git a/src/libs/Prefetch/registerPrefetchOnAppStart/index.ts b/src/libs/Prefetch/registerPrefetchOnAppStart/index.ts index 92508eeb3a8d..f19e7788627d 100644 --- a/src/libs/Prefetch/registerPrefetchOnAppStart/index.ts +++ b/src/libs/Prefetch/registerPrefetchOnAppStart/index.ts @@ -1,3 +1,7 @@ +/** + * Registers a native startup prefetch (and its Authenticate token-refresh config) with + * `react-native-nitro-fetch` so whitelisted API requests can be fetched before the JS bundle loads. + */ import {AUTHENTICATION_COMMAND} from '@libs/API/types'; import {getApiRoot} from '@libs/ApiUtils'; import Log from '@libs/Log'; From a3477c177fb6ab860e1a68a518c23f42a1efb73a Mon Sep 17 00:00:00 2001 From: chrispader Date: Tue, 28 Jul 2026 10:32:13 +0200 Subject: [PATCH 07/14] deps: update NitroModules and Nitrogen --- ios/Podfile.lock | 4 +-- package-lock.json | 69 ++++++++++++++++++++--------------------------- package.json | 4 +-- 3 files changed, 33 insertions(+), 44 deletions(-) diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 2c53ac97299c..741d74122e11 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -440,7 +440,7 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga - - NitroModules (0.36.1): + - NitroModules (0.36.3): - boost - DoubleConversion - fast_float @@ -4898,7 +4898,7 @@ SPEC CHECKSUMS: MapboxMaps: 22340ae0e0d47b2e4761019b1d6c569ab04e088d nanopb: fad817b59e0457d11a5dfbde799381cd727c1275 NitroFetch: 4941ca1e41fc72ebdad33c50c85fee59a3dd6871 - NitroModules: 8b9fffe988a1ff6d0e9409db52035837a25de35d + NitroModules: 1961fff19baf1f4a5221dd2fea7d4de95941f9e4 NWWebSocket: b4741420f1976e1dff4da3edad00c401e4f1d769 Onfido: 65454f91d10758193c857fd149417f6efbea84c5 onfido-react-native-sdk: bb8cfd9198e2e97978461d969497d18b37e45ca7 diff --git a/package-lock.json b/package-lock.json index 391f887b37bc..38e527a2e39d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -122,7 +122,7 @@ "react-native-keyboard-controller": "1.21.0-beta.1", "react-native-localize": "^3.5.4", "react-native-nitro-fetch": "1.5.4", - "react-native-nitro-modules": "0.36.1", + "react-native-nitro-modules": "0.36.3", "react-native-nitro-sqlite": "9.6.0", "react-native-onyx": "3.0.90", "react-native-pager-view": "8.0.0", @@ -261,7 +261,7 @@ "lefthook": "2.1.9", "link": "^2.1.1", "memfs": "^4.6.0", - "nitrogen": "0.35.5", + "nitrogen": "0.36.3", "onchange": "^7.1.0", "openai": "^7.0.0", "oxc-transform": "0.136.0", @@ -17585,9 +17585,9 @@ } }, "node_modules/@ts-morph/common": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.28.1.tgz", - "integrity": "sha512-W74iWf7ILp1ZKNYXY5qbddNaml7e9Sedv5lvU1V8lftlitkc9Pq1A+jlH23ltDgWYeZFFEqGCD1Ies9hqu3O+g==", + "version": "0.29.0", + "resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.29.0.tgz", + "integrity": "sha512-35oUmphHbJvQ/+UTwFNme/t2p3FoKiGJ5auTjjpNTop2dyREspirjMy82PLSC1pnDJ8ah1GU98hwpVt64YXQsg==", "dev": true, "license": "MIT", "dependencies": { @@ -17607,26 +17607,26 @@ } }, "node_modules/@ts-morph/common/node_modules/brace-expansion": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", - "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", + "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/@ts-morph/common/node_modules/minimatch": { - "version": "10.2.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", - "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^5.0.2" + "brace-expansion": "^5.0.8" }, "engines": { "node": "18 || 20 || >=22" @@ -33558,17 +33558,17 @@ "license": "MIT" }, "node_modules/nitrogen": { - "version": "0.35.5", - "resolved": "https://registry.npmjs.org/nitrogen/-/nitrogen-0.35.5.tgz", - "integrity": "sha512-Ofl4aTW2rd44+hBcUwLXu01ViHikn2SDI7zOYc+6NcKSpnhoj42k1FwyvRj1QZPDMUT64Bwlb0DYw7Hrfd3BfQ==", + "version": "0.36.3", + "resolved": "https://registry.npmjs.org/nitrogen/-/nitrogen-0.36.3.tgz", + "integrity": "sha512-WOJLBBWyZPPPyBFYyScwGd1vfy6KvSTjlTug6eJy6RcTXDiMSiz7CjUmOPN6CIRN8o3PC3d/DLqDpo4/eGKbxA==", "dev": true, "license": "MIT", "dependencies": { "chalk": "^5.3.0", - "react-native-nitro-modules": "^0.35.5", - "ts-morph": "^27.0.0", + "react-native-nitro-modules": "^0.36.3", + "ts-morph": "^28.0.0", "yargs": "^18.0.0", - "zod": "^4.0.5" + "zod": "^4.4.3" }, "bin": { "nitrogen": "lib/index.js" @@ -33587,21 +33587,10 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/nitrogen/node_modules/react-native-nitro-modules": { - "version": "0.35.10", - "resolved": "https://registry.npmjs.org/react-native-nitro-modules/-/react-native-nitro-modules-0.35.10.tgz", - "integrity": "sha512-KsySOAIkbSTjNiX2GvXiNT9P5DMeZd99yvtE/+Lw7RXV7Ztxh9Z+YyAbYkBqadhN5J/KXXuPjhRYgyLOIZgsbQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "react": "*", - "react-native": "*" - } - }, "node_modules/nitrogen/node_modules/zod": { - "version": "4.1.12", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.12.tgz", - "integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "dev": true, "license": "MIT", "funding": { @@ -35868,9 +35857,9 @@ } }, "node_modules/react-native-nitro-modules": { - "version": "0.36.1", - "resolved": "https://registry.npmjs.org/react-native-nitro-modules/-/react-native-nitro-modules-0.36.1.tgz", - "integrity": "sha512-kBv/VvKqAmkXAvP1DxJMC9b/fRhh7JdSO4EUnPP46hJjrIFeFR8AwKm8mYaKZEuF014M/TVdv2vomVUW0umsQQ==", + "version": "0.36.3", + "resolved": "https://registry.npmjs.org/react-native-nitro-modules/-/react-native-nitro-modules-0.36.3.tgz", + "integrity": "sha512-8nVnqxN2ZJdKw8u6HnUXBStd/ERRMzvN93g+A48EhWWeYQVx4idKYRMuNGXXsPUNIWOreNKs2hky2PBfnCif/g==", "license": "MIT", "peerDependencies": { "react": "*", @@ -40138,13 +40127,13 @@ } }, "node_modules/ts-morph": { - "version": "27.0.2", - "resolved": "https://registry.npmjs.org/ts-morph/-/ts-morph-27.0.2.tgz", - "integrity": "sha512-fhUhgeljcrdZ+9DZND1De1029PrE+cMkIP7ooqkLRTrRLTqcki2AstsyJm0vRNbTbVCNJ0idGlbBrfqc7/nA8w==", + "version": "28.0.0", + "resolved": "https://registry.npmjs.org/ts-morph/-/ts-morph-28.0.0.tgz", + "integrity": "sha512-Wp3tnZ2bzwxyTZMtgWVzXDfm7lB1Drz+y9DmmYH/L702PQhPyVrp3pkou3yIz4qjS14GY9kcpmLiOOMvl8oG1g==", "dev": true, "license": "MIT", "dependencies": { - "@ts-morph/common": "~0.28.1", + "@ts-morph/common": "~0.29.0", "code-block-writer": "^13.0.3" } }, diff --git a/package.json b/package.json index 814c8646492c..2ac24ae51003 100644 --- a/package.json +++ b/package.json @@ -196,7 +196,7 @@ "react-native-keyboard-controller": "1.21.0-beta.1", "react-native-localize": "^3.5.4", "react-native-nitro-fetch": "1.5.4", - "react-native-nitro-modules": "0.36.1", + "react-native-nitro-modules": "0.36.3", "react-native-nitro-sqlite": "9.6.0", "react-native-onyx": "3.0.90", "react-native-pager-view": "8.0.0", @@ -335,7 +335,7 @@ "lefthook": "2.1.9", "link": "^2.1.1", "memfs": "^4.6.0", - "nitrogen": "0.35.5", + "nitrogen": "0.36.3", "onchange": "^7.1.0", "openai": "^7.0.0", "oxc-transform": "0.136.0", From dbb29daf76a4dc5ebeee5aa6ab059942a0f9fafb Mon Sep 17 00:00:00 2001 From: chrispader Date: Tue, 28 Jul 2026 10:32:37 +0200 Subject: [PATCH 08/14] chore: update nitrogen-generated files --- .../generated/android/c++/JContact.hpp | 34 +++++++++---------- .../generated/android/c++/JContactFields.hpp | 2 +- .../android/c++/JHybridContactsModuleSpec.cpp | 16 ++++----- .../generated/android/c++/JStringHolder.hpp | 2 +- .../kotlin/com/margelo/nitro/utils/Contact.kt | 2 +- .../com/margelo/nitro/utils/StringHolder.kt | 2 +- 6 files changed, 29 insertions(+), 29 deletions(-) diff --git a/modules/ExpensifyNitroUtils/nitrogen/generated/android/c++/JContact.hpp b/modules/ExpensifyNitroUtils/nitrogen/generated/android/c++/JContact.hpp index bcaa90160b67..6725f3ee136d 100644 --- a/modules/ExpensifyNitroUtils/nitrogen/generated/android/c++/JContact.hpp +++ b/modules/ExpensifyNitroUtils/nitrogen/generated/android/c++/JContact.hpp @@ -21,7 +21,7 @@ namespace margelo::nitro::utils { using namespace facebook; /** - * The C++ JNI bridge between the C++ struct "Contact" and the the Kotlin data class "Contact". + * The C++ JNI bridge between the C++ struct "Contact" and the Kotlin data class "Contact". */ struct JContact final: public jni::JavaClass { public: @@ -48,26 +48,26 @@ namespace margelo::nitro::utils { return Contact( firstName != nullptr ? std::make_optional(firstName->toStdString()) : std::nullopt, lastName != nullptr ? std::make_optional(lastName->toStdString()) : std::nullopt, - phoneNumbers != nullptr ? std::make_optional([&]() { - size_t __size = phoneNumbers->size(); + phoneNumbers != nullptr ? std::make_optional([&](auto&& __input) { + size_t __size = __input->size(); std::vector __vector; __vector.reserve(__size); for (size_t __i = 0; __i < __size; __i++) { - auto __element = phoneNumbers->getElement(__i); + auto __element = __input->getElement(__i); __vector.push_back(__element->toCpp()); } return __vector; - }()) : std::nullopt, - emailAddresses != nullptr ? std::make_optional([&]() { - size_t __size = emailAddresses->size(); + }(phoneNumbers)) : std::nullopt, + emailAddresses != nullptr ? std::make_optional([&](auto&& __input) { + size_t __size = __input->size(); std::vector __vector; __vector.reserve(__size); for (size_t __i = 0; __i < __size; __i++) { - auto __element = emailAddresses->getElement(__i); + auto __element = __input->getElement(__i); __vector.push_back(__element->toCpp()); } return __vector; - }()) : std::nullopt, + }(emailAddresses)) : std::nullopt, imageData != nullptr ? std::make_optional(imageData->toStdString()) : std::nullopt ); } @@ -85,26 +85,26 @@ namespace margelo::nitro::utils { clazz, value.firstName.has_value() ? jni::make_jstring(value.firstName.value()) : nullptr, value.lastName.has_value() ? jni::make_jstring(value.lastName.value()) : nullptr, - value.phoneNumbers.has_value() ? [&]() { - size_t __size = value.phoneNumbers.value().size(); + value.phoneNumbers.has_value() ? [&](auto&& __input) { + size_t __size = __input.size(); jni::local_ref> __array = jni::JArrayClass::newArray(__size); for (size_t __i = 0; __i < __size; __i++) { - const auto& __element = value.phoneNumbers.value()[__i]; + const auto& __element = __input[__i]; auto __elementJni = JStringHolder::fromCpp(__element); __array->setElement(__i, *__elementJni); } return __array; - }() : nullptr, - value.emailAddresses.has_value() ? [&]() { - size_t __size = value.emailAddresses.value().size(); + }(value.phoneNumbers.value()) : nullptr, + value.emailAddresses.has_value() ? [&](auto&& __input) { + size_t __size = __input.size(); jni::local_ref> __array = jni::JArrayClass::newArray(__size); for (size_t __i = 0; __i < __size; __i++) { - const auto& __element = value.emailAddresses.value()[__i]; + const auto& __element = __input[__i]; auto __elementJni = JStringHolder::fromCpp(__element); __array->setElement(__i, *__elementJni); } return __array; - }() : nullptr, + }(value.emailAddresses.value()) : nullptr, value.imageData.has_value() ? jni::make_jstring(value.imageData.value()) : nullptr ); } diff --git a/modules/ExpensifyNitroUtils/nitrogen/generated/android/c++/JContactFields.hpp b/modules/ExpensifyNitroUtils/nitrogen/generated/android/c++/JContactFields.hpp index 8252d90ad4ef..ae0f1c356091 100644 --- a/modules/ExpensifyNitroUtils/nitrogen/generated/android/c++/JContactFields.hpp +++ b/modules/ExpensifyNitroUtils/nitrogen/generated/android/c++/JContactFields.hpp @@ -15,7 +15,7 @@ namespace margelo::nitro::utils { using namespace facebook; /** - * The C++ JNI bridge between the C++ enum "ContactFields" and the the Kotlin enum "ContactFields". + * The C++ JNI bridge between the C++ enum "ContactFields" and the Kotlin enum "ContactFields". */ struct JContactFields final: public jni::JavaClass { public: diff --git a/modules/ExpensifyNitroUtils/nitrogen/generated/android/c++/JHybridContactsModuleSpec.cpp b/modules/ExpensifyNitroUtils/nitrogen/generated/android/c++/JHybridContactsModuleSpec.cpp index 812c363b0d36..8b9cf258107e 100644 --- a/modules/ExpensifyNitroUtils/nitrogen/generated/android/c++/JHybridContactsModuleSpec.cpp +++ b/modules/ExpensifyNitroUtils/nitrogen/generated/android/c++/JHybridContactsModuleSpec.cpp @@ -61,30 +61,30 @@ namespace margelo::nitro::utils { // Methods std::shared_ptr>> JHybridContactsModuleSpec::getAll(const std::vector& keys) { static const auto method = _javaPart->javaClassStatic()->getMethod(jni::alias_ref> /* keys */)>("getAll"); - auto __result = method(_javaPart, [&]() { - size_t __size = keys.size(); + auto __result = method(_javaPart, [&](auto&& __input) { + size_t __size = __input.size(); jni::local_ref> __array = jni::JArrayClass::newArray(__size); for (size_t __i = 0; __i < __size; __i++) { - const auto& __element = keys[__i]; + const auto& __element = __input[__i]; auto __elementJni = JContactFields::fromCpp(__element); __array->setElement(__i, *__elementJni); } return __array; - }()); + }(keys)); return [&]() { auto __promise = Promise>::create(); __result->cthis()->addOnResolvedListener([=](const jni::alias_ref& __boxedResult) { auto __result = jni::static_ref_cast>(__boxedResult); - __promise->resolve([&]() { - size_t __size = __result->size(); + __promise->resolve([&](auto&& __input) { + size_t __size = __input->size(); std::vector __vector; __vector.reserve(__size); for (size_t __i = 0; __i < __size; __i++) { - auto __element = __result->getElement(__i); + auto __element = __input->getElement(__i); __vector.push_back(__element->toCpp()); } return __vector; - }()); + }(__result)); }); __result->cthis()->addOnRejectedListener([=](const jni::alias_ref& __throwable) { jni::JniException __jniError(__throwable); diff --git a/modules/ExpensifyNitroUtils/nitrogen/generated/android/c++/JStringHolder.hpp b/modules/ExpensifyNitroUtils/nitrogen/generated/android/c++/JStringHolder.hpp index a26b81bdbe47..3344931a0385 100644 --- a/modules/ExpensifyNitroUtils/nitrogen/generated/android/c++/JStringHolder.hpp +++ b/modules/ExpensifyNitroUtils/nitrogen/generated/android/c++/JStringHolder.hpp @@ -17,7 +17,7 @@ namespace margelo::nitro::utils { using namespace facebook; /** - * The C++ JNI bridge between the C++ struct "StringHolder" and the the Kotlin data class "StringHolder". + * The C++ JNI bridge between the C++ struct "StringHolder" and the Kotlin data class "StringHolder". */ struct JStringHolder final: public jni::JavaClass { public: diff --git a/modules/ExpensifyNitroUtils/nitrogen/generated/android/kotlin/com/margelo/nitro/utils/Contact.kt b/modules/ExpensifyNitroUtils/nitrogen/generated/android/kotlin/com/margelo/nitro/utils/Contact.kt index 816608d30d71..4077492eb988 100644 --- a/modules/ExpensifyNitroUtils/nitrogen/generated/android/kotlin/com/margelo/nitro/utils/Contact.kt +++ b/modules/ExpensifyNitroUtils/nitrogen/generated/android/kotlin/com/margelo/nitro/utils/Contact.kt @@ -47,7 +47,7 @@ data class Contact( } override fun hashCode(): Int { - return arrayOf( + return arrayOf( firstName, lastName, phoneNumbers, diff --git a/modules/ExpensifyNitroUtils/nitrogen/generated/android/kotlin/com/margelo/nitro/utils/StringHolder.kt b/modules/ExpensifyNitroUtils/nitrogen/generated/android/kotlin/com/margelo/nitro/utils/StringHolder.kt index abe48e289f4e..5cee30f9947f 100644 --- a/modules/ExpensifyNitroUtils/nitrogen/generated/android/kotlin/com/margelo/nitro/utils/StringHolder.kt +++ b/modules/ExpensifyNitroUtils/nitrogen/generated/android/kotlin/com/margelo/nitro/utils/StringHolder.kt @@ -31,7 +31,7 @@ data class StringHolder( } override fun hashCode(): Int { - return arrayOf( + return arrayOf( value ).contentDeepHashCode() } From 8f3045266bc7fbc145e8089cae4fcd96a2efea1f Mon Sep 17 00:00:00 2001 From: chrispader Date: Tue, 28 Jul 2026 10:37:50 +0200 Subject: [PATCH 09/14] fix: update warn log tag --- src/libs/Prefetch/registerPrefetchOnAppStart/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libs/Prefetch/registerPrefetchOnAppStart/index.ts b/src/libs/Prefetch/registerPrefetchOnAppStart/index.ts index f19e7788627d..64bee6348a27 100644 --- a/src/libs/Prefetch/registerPrefetchOnAppStart/index.ts +++ b/src/libs/Prefetch/registerPrefetchOnAppStart/index.ts @@ -77,7 +77,7 @@ const registerPrefetchOnAppStart: RegisterPrefetchOnAppStart = ({prefetchKey, fe registerPrefetchTokenRefresh(); prefetchOnAppStart(url, fetchParams).catch((error) => { - Log.warn(`[HttpUtils] prefetchOnAppStart failed for ${command}`, {error, fetchParams, url}); + Log.warn(`[NitroFetch] prefetchOnAppStart failed for ${command}`, {error, fetchParams, url}); }); }; From 0dc6011e6059b6a13dc98dc35c0c904b79978c2d Mon Sep 17 00:00:00 2001 From: chrispader Date: Tue, 28 Jul 2026 10:47:09 +0200 Subject: [PATCH 10/14] fix: add comment explaining `appversion` param omission --- src/libs/Prefetch/registerPrefetchOnAppStart/index.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/libs/Prefetch/registerPrefetchOnAppStart/index.ts b/src/libs/Prefetch/registerPrefetchOnAppStart/index.ts index 64bee6348a27..c15d73a67b24 100644 --- a/src/libs/Prefetch/registerPrefetchOnAppStart/index.ts +++ b/src/libs/Prefetch/registerPrefetchOnAppStart/index.ts @@ -23,6 +23,10 @@ function buildAuthenticateBody(credentials: Credentials): string { // NitroFetch token refresh runs outside the JS request pipeline, so we must serialize the // Authenticate body here instead of going through Request.post() and enhanceParameters(). const {partnerName, partnerPassword} = getPartnerCredentials(credentials.autoGeneratedLogin); + + // The `appversion` parameter is omitted intentionally here. + // This is because it can cause issues during app updates and + // it is not needed by the "Authenticate" command for token refresh. // eslint-disable-next-line @typescript-eslint/naming-convention const {referer, platform, api_setCookie, isFromDevEnv} = getBaseRequestParameters(); From 50895fec6e03426d20a8a35a4d63a4a08d14a830 Mon Sep 17 00:00:00 2001 From: chrispader Date: Tue, 28 Jul 2026 10:52:02 +0200 Subject: [PATCH 11/14] fix: catch `clearPrefetchOnAppStart` individually --- src/libs/actions/Session/updateSessionAuthTokens.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/libs/actions/Session/updateSessionAuthTokens.ts b/src/libs/actions/Session/updateSessionAuthTokens.ts index eb116807b392..aff760caf7f0 100644 --- a/src/libs/actions/Session/updateSessionAuthTokens.ts +++ b/src/libs/actions/Session/updateSessionAuthTokens.ts @@ -1,13 +1,16 @@ +import Log from '@libs/Log'; import clearPrefetchOnAppStart from '@libs/Prefetch/clearPrefetchOnAppStart'; import ONYXKEYS from '@src/ONYXKEYS'; import Onyx from 'react-native-onyx'; -export default async function updateSessionAuthTokens(authToken?: string, encryptedAuthToken?: string) { +export default function updateSessionAuthTokens(authToken?: string, encryptedAuthToken?: string) { // Startup prefetches are persisted natively across launches. Drop any queue/token-refresh // config tied to the previous auth token before saving the replacement. - await clearPrefetchOnAppStart(); + clearPrefetchOnAppStart().catch((error) => { + Log.warn('[NitroFetch] clearPrefetchOnAppStart failed', {error}); + }); return Onyx.merge(ONYXKEYS.SESSION, {authToken, encryptedAuthToken, creationDate: new Date().getTime()}); } From a1d24b28dadb76b412e64319b74a635260536693 Mon Sep 17 00:00:00 2001 From: chrispader Date: Tue, 28 Jul 2026 10:53:15 +0200 Subject: [PATCH 12/14] refactor: re-use `getCommandURL` util function --- src/libs/Prefetch/registerPrefetchOnAppStart/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/libs/Prefetch/registerPrefetchOnAppStart/index.ts b/src/libs/Prefetch/registerPrefetchOnAppStart/index.ts index c15d73a67b24..57affa277de0 100644 --- a/src/libs/Prefetch/registerPrefetchOnAppStart/index.ts +++ b/src/libs/Prefetch/registerPrefetchOnAppStart/index.ts @@ -3,7 +3,7 @@ * `react-native-nitro-fetch` so whitelisted API requests can be fetched before the JS bundle loads. */ import {AUTHENTICATION_COMMAND} from '@libs/API/types'; -import {getApiRoot} from '@libs/ApiUtils'; +import {getApiRoot, getCommandURL} from '@libs/ApiUtils'; import Log from '@libs/Log'; import {getBaseRequestParameters} from '@libs/Network/enhanceParameters'; import {getCredentials} from '@libs/Network/NetworkStore'; @@ -56,7 +56,7 @@ function registerPrefetchTokenRefresh(): void { return; } - const authenticateURL = `${getApiRoot()}api/${AUTHENTICATION_COMMAND}?`; + const authenticateURL = `${getCommandURL({command: AUTHENTICATION_COMMAND})}`; registerTokenRefresh({ target: NITRO_FETCH_TARGET, From 49491980a60864eb941691053f0f24c806835b68 Mon Sep 17 00:00:00 2001 From: chrispader Date: Tue, 28 Jul 2026 10:58:40 +0200 Subject: [PATCH 13/14] fix: isolate startup prefetch state between accounts --- jest/setup.ts | 1 + server/stubs/react-native-nitro-fetch.ts | 4 +- src/libs/Network/NetworkStore.ts | 7 ++++ .../Prefetch/clearPrefetchOnAppStart/index.ts | 13 ++---- .../Prefetch/preparePrefetchRequest/index.ts | 5 ++- .../registerPrefetchOnAppStart/index.ts | 2 +- src/libs/actions/SignInRedirect.ts | 6 ++- .../actions/clearOnyxAndSeedFullReconnect.ts | 6 ++- tests/actions/SessionTest.ts | 10 ++--- tests/unit/AuthPrefetchTest.ts | 10 ++++- tests/unit/PreparePrefetchRequestTest.ts | 40 +++++++++++++++++++ 11 files changed, 84 insertions(+), 20 deletions(-) create mode 100644 tests/unit/PreparePrefetchRequestTest.ts diff --git a/jest/setup.ts b/jest/setup.ts index 72256c7efa57..76b88f58d8e6 100644 --- a/jest/setup.ts +++ b/jest/setup.ts @@ -320,6 +320,7 @@ jest.mock('react-native-nitro-fetch', () => ({ registerTokenRefresh: jest.fn(), clearTokenRefresh: jest.fn(), removeFromAutoPrefetch: jest.fn(() => Promise.resolve()), + removeAllFromAutoprefetch: jest.fn(() => Promise.resolve()), })); jest.mock('@shopify/react-native-skia', () => ({ diff --git a/server/stubs/react-native-nitro-fetch.ts b/server/stubs/react-native-nitro-fetch.ts index 704ff3387e20..0378ddecff1a 100644 --- a/server/stubs/react-native-nitro-fetch.ts +++ b/server/stubs/react-native-nitro-fetch.ts @@ -6,6 +6,8 @@ function registerTokenRefresh() {} function removeFromAutoPrefetch() {} -export {clearTokenRefresh, prefetchOnAppStart, registerTokenRefresh, removeFromAutoPrefetch}; +function removeAllFromAutoprefetch() {} + +export {clearTokenRefresh, prefetchOnAppStart, registerTokenRefresh, removeFromAutoPrefetch, removeAllFromAutoprefetch}; export default {}; diff --git a/src/libs/Network/NetworkStore.ts b/src/libs/Network/NetworkStore.ts index db04596fc8ee..1586dd74957b 100644 --- a/src/libs/Network/NetworkStore.ts +++ b/src/libs/Network/NetworkStore.ts @@ -13,6 +13,7 @@ let credentials: Credentials | null | undefined; let lastShortAuthToken: string | null | undefined; let authToken: string | null | undefined; let authTokenType: ValueOf | null; +let accountID: number | null | undefined; let authenticating = false; let resolveIsReadyPromise: (args?: unknown[]) => void; @@ -45,6 +46,7 @@ Onyx.connectWithoutView({ callback: (val) => { authToken = val?.authToken ?? null; authTokenType = val?.authTokenType ?? null; + accountID = val?.accountID ?? null; checkRequiredData(); }, }); @@ -67,6 +69,10 @@ function getAuthToken(): string | null | undefined { return authToken; } +function getAccountID(): number | null | undefined { + return accountID; +} + function getLastShortAuthToken(): string | null | undefined { return lastShortAuthToken; } @@ -97,6 +103,7 @@ function setIsAuthenticating(val: boolean) { export { getAuthToken, + getAccountID, setAuthToken, getCurrentUserEmail, hasReadRequiredDataFromStorage, diff --git a/src/libs/Prefetch/clearPrefetchOnAppStart/index.ts b/src/libs/Prefetch/clearPrefetchOnAppStart/index.ts index bf8242bb3b73..f50a2b195374 100644 --- a/src/libs/Prefetch/clearPrefetchOnAppStart/index.ts +++ b/src/libs/Prefetch/clearPrefetchOnAppStart/index.ts @@ -2,22 +2,17 @@ * Clears the prefetch on app start. */ import Log from '@libs/Log'; -import PrefetchQueries from '@libs/Prefetch/PrefetchQueries'; -import {clearTokenRefresh, removeFromAutoPrefetch} from 'react-native-nitro-fetch'; +import {clearTokenRefresh, removeAllFromAutoprefetch} from 'react-native-nitro-fetch'; import type ClearPrefetchOnAppStart from './types'; const clearPrefetchOnAppStart: ClearPrefetchOnAppStart = async () => { clearTokenRefresh('fetch'); - await Promise.all( - Array.from(PrefetchQueries).map(async (command) => { - await removeFromAutoPrefetch(command).catch((error) => { - Log.warn(`[HttpUtils] removeFromAutoPrefetch failed for ${command}`, {error}); - }); - }), - ); + await removeAllFromAutoprefetch().catch((error) => { + Log.warn('[HttpUtils] removeAllFromAutoprefetch failed', {error}); + }); }; export default clearPrefetchOnAppStart; diff --git a/src/libs/Prefetch/preparePrefetchRequest/index.ts b/src/libs/Prefetch/preparePrefetchRequest/index.ts index 791525a47128..6963153ff1ac 100644 --- a/src/libs/Prefetch/preparePrefetchRequest/index.ts +++ b/src/libs/Prefetch/preparePrefetchRequest/index.ts @@ -1,3 +1,4 @@ +import {getAccountID} from '@libs/Network/NetworkStore'; /** * Prepares a prefetch request for a given command. */ @@ -8,7 +9,9 @@ import type PreparePrefetchRequest from './types'; const preparePrefetchRequest: PreparePrefetchRequest = (command) => { // Prefetch the request on next app start if the prefetch key is present in the headers // This allows to fetch the request natively before the JS bundle is loaded. Once the request with this prefetch key is made, it will already be cached and served from the cache. - const prefetchKey = command && PrefetchQueries.has(command) ? command : undefined; + // Native cache entries outlive the JS session, so never share a command-only key across accounts. + const accountID = getAccountID(); + const prefetchKey = command && PrefetchQueries.has(command) && accountID !== null && accountID !== undefined ? `${command}:${accountID}` : undefined; const prefetchHeaders = prefetchKey ? { diff --git a/src/libs/Prefetch/registerPrefetchOnAppStart/index.ts b/src/libs/Prefetch/registerPrefetchOnAppStart/index.ts index 57affa277de0..8a18ea9ef288 100644 --- a/src/libs/Prefetch/registerPrefetchOnAppStart/index.ts +++ b/src/libs/Prefetch/registerPrefetchOnAppStart/index.ts @@ -3,7 +3,7 @@ * `react-native-nitro-fetch` so whitelisted API requests can be fetched before the JS bundle loads. */ import {AUTHENTICATION_COMMAND} from '@libs/API/types'; -import {getApiRoot, getCommandURL} from '@libs/ApiUtils'; +import {getCommandURL} from '@libs/ApiUtils'; import Log from '@libs/Log'; import {getBaseRequestParameters} from '@libs/Network/enhanceParameters'; import {getCredentials} from '@libs/Network/NetworkStore'; diff --git a/src/libs/actions/SignInRedirect.ts b/src/libs/actions/SignInRedirect.ts index 874c2a083355..8fb9b9f79544 100644 --- a/src/libs/actions/SignInRedirect.ts +++ b/src/libs/actions/SignInRedirect.ts @@ -1,6 +1,7 @@ import {getMicroSecondOnyxErrorWithMessage} from '@libs/ErrorUtils'; import {clearSessionStorage} from '@libs/Navigation/helpers/lastVisitedTabPathUtils'; import {getIsOffline} from '@libs/NetworkState'; +import clearPrefetchOnAppStart from '@libs/Prefetch/clearPrefetchOnAppStart'; import CONFIG from '@src/CONFIG'; import type {OnyxKey} from '@src/ONYXKEYS'; @@ -92,7 +93,10 @@ function clearStorageAndRedirect(errorMessage?: string, isSAMLReauthentication?: Onyx.merge(ONYXKEYS.ACCOUNT, {isLoading: true}); } - return Onyx.clear(keysToPreserve).then(() => { + return Onyx.clear(keysToPreserve).then(async () => { + // Requests may be processed while sign-out is in progress. Clear again after credentials have been removed so none of those requests remain queued for the next startup. + await clearPrefetchOnAppStart(); + if (CONFIG.IS_HYBRID_APP) { resetSignInFlow(); HybridAppModule.signOutFromOldDot(); diff --git a/src/libs/actions/clearOnyxAndSeedFullReconnect.ts b/src/libs/actions/clearOnyxAndSeedFullReconnect.ts index 7e37860cd0e3..35748063bd47 100644 --- a/src/libs/actions/clearOnyxAndSeedFullReconnect.ts +++ b/src/libs/actions/clearOnyxAndSeedFullReconnect.ts @@ -28,7 +28,11 @@ async function clearOnyxAndSeedFullReconnect(keysToPreserve: OnyxKey[], extraSee ...extraSeeds, [ONYXKEYS.LAST_FULL_RECONNECT_TIME]: DateUtils.getDBTime(), }; - return Onyx.multiSet(seeds).then(() => Onyx.clear([...keysToPreserve, ...(Object.keys(seeds) as OnyxKey[])])); + await Onyx.multiSet(seeds); + await Onyx.clear([...keysToPreserve, ...(Object.keys(seeds) as OnyxKey[])]); + + // A request can be processed between the initial cleanup and the Onyx reset. Clear the queue again after the old credentials are gone. + await clearPrefetchOnAppStart(); } export default clearOnyxAndSeedFullReconnect; diff --git a/tests/actions/SessionTest.ts b/tests/actions/SessionTest.ts index edb1808ef29f..f3b0649a48b5 100644 --- a/tests/actions/SessionTest.ts +++ b/tests/actions/SessionTest.ts @@ -30,7 +30,7 @@ import type {Credentials, Session} from '@src/types/onyx'; import type {OnyxEntry} from 'react-native-onyx'; import {openAuthSessionAsync} from 'expo-web-browser'; -import {clearTokenRefresh, removeFromAutoPrefetch} from 'react-native-nitro-fetch'; +import {clearTokenRefresh, removeAllFromAutoprefetch} from 'react-native-nitro-fetch'; import Onyx from 'react-native-onyx'; import * as TestHelper from '../utils/TestHelper'; @@ -463,7 +463,7 @@ describe('Session', () => { await SessionUtil.signOut({authToken: 'testAuthToken'}); expect(clearTokenRefresh).toHaveBeenCalledWith('fetch'); - expect(removeFromAutoPrefetch).toHaveBeenCalledWith(WRITE_COMMANDS.RECONNECT_APP); + expect(removeAllFromAutoprefetch).toHaveBeenCalled(); setHasRadio(true); await waitForBatchedUpdates(); @@ -471,16 +471,16 @@ describe('Session', () => { test('SignOut should clear native startup prefetch state before LOG_OUT', async () => { const clearTokenRefreshMock = jest.mocked(clearTokenRefresh); - const removeFromAutoPrefetchMock = jest.mocked(removeFromAutoPrefetch); + const removeAllFromAutoprefetchMock = jest.mocked(removeAllFromAutoprefetch); const makeRequestSpy = jest.spyOn(API, 'makeRequestWithSideEffects').mockResolvedValue(undefined); await SessionUtil.signOut({authToken: 'testAuthToken'}); expect(clearTokenRefreshMock).toHaveBeenCalledWith('fetch'); - expect(removeFromAutoPrefetchMock).toHaveBeenCalledWith(WRITE_COMMANDS.RECONNECT_APP); + expect(removeAllFromAutoprefetchMock).toHaveBeenCalled(); expect(makeRequestSpy).toHaveBeenCalledWith(SIDE_EFFECT_REQUEST_COMMANDS.LOG_OUT, expect.objectContaining({authToken: 'testAuthToken'}), {}); expect(clearTokenRefreshMock.mock.invocationCallOrder.at(0)).toBeLessThan(makeRequestSpy.mock.invocationCallOrder.at(0) ?? 0); - expect(removeFromAutoPrefetchMock.mock.invocationCallOrder.at(0)).toBeLessThan(makeRequestSpy.mock.invocationCallOrder.at(0) ?? 0); + expect(removeAllFromAutoprefetchMock.mock.invocationCallOrder.at(0)).toBeLessThan(makeRequestSpy.mock.invocationCallOrder.at(0) ?? 0); makeRequestSpy.mockRestore(); }); diff --git a/tests/unit/AuthPrefetchTest.ts b/tests/unit/AuthPrefetchTest.ts index dd7e9a0464e1..379e7a1fc2b1 100644 --- a/tests/unit/AuthPrefetchTest.ts +++ b/tests/unit/AuthPrefetchTest.ts @@ -7,6 +7,12 @@ const mockClear = jest.fn(); const mockGetDBTime = jest.fn(); jest.mock('@libs/Prefetch/clearPrefetchOnAppStart', () => mockClearPrefetchOnAppStart); +jest.mock('@libs/Log', () => ({ + __esModule: true, + default: { + warn: jest.fn(), + }, +})); jest.mock('@libs/DateUtils', () => ({ getDBTime: mockGetDBTime, })); @@ -22,6 +28,7 @@ jest.mock('react-native-onyx', () => ({ describe('auth startup prefetch cleanup', () => { beforeEach(() => { jest.clearAllMocks(); + mockClearPrefetchOnAppStart.mockResolvedValue(undefined); mockMerge.mockResolvedValue(undefined); mockMultiSet.mockResolvedValue(undefined); mockClear.mockResolvedValue(undefined); @@ -50,12 +57,13 @@ describe('auth startup prefetch cleanup', () => { await clearOnyxAndSeedFullReconnect([ONYXKEYS.SESSION], {[ONYXKEYS.IS_LOADING_APP]: true}); - expect(mockClearPrefetchOnAppStart).toHaveBeenCalledTimes(1); + expect(mockClearPrefetchOnAppStart).toHaveBeenCalledTimes(2); expect(mockMultiSet).toHaveBeenCalledWith({ [ONYXKEYS.IS_LOADING_APP]: true, [ONYXKEYS.LAST_FULL_RECONNECT_TIME]: '2026-07-16 12:00:00.000', }); expect(mockClear).toHaveBeenCalledWith([ONYXKEYS.SESSION, ONYXKEYS.IS_LOADING_APP, ONYXKEYS.LAST_FULL_RECONNECT_TIME]); expect(mockClearPrefetchOnAppStart.mock.invocationCallOrder.at(0)).toBeLessThan(mockMultiSet.mock.invocationCallOrder.at(0) ?? 0); + expect(mockClearPrefetchOnAppStart.mock.invocationCallOrder.at(1)).toBeGreaterThan(mockClear.mock.invocationCallOrder.at(0) ?? 0); }); }); diff --git a/tests/unit/PreparePrefetchRequestTest.ts b/tests/unit/PreparePrefetchRequestTest.ts new file mode 100644 index 000000000000..0e0f944adddf --- /dev/null +++ b/tests/unit/PreparePrefetchRequestTest.ts @@ -0,0 +1,40 @@ +import {WRITE_COMMANDS} from '@libs/API/types'; + +const mockGetAccountID = jest.fn(); + +jest.mock('@libs/Network/NetworkStore', () => ({ + getAccountID: mockGetAccountID, +})); + +describe('preparePrefetchRequest', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('uses an account-scoped key for startup prefetches', async () => { + // Given a signed-in account, because native prefetch responses outlive the JS session + mockGetAccountID.mockReturnValue(123); + + // When a command eligible for startup prefetching is prepared + const preparePrefetchRequest = (await import('@libs/Prefetch/preparePrefetchRequest')).default; + const result = preparePrefetchRequest(WRITE_COMMANDS.RECONNECT_APP); + + // Then its cache key is bound to that account and cannot be reused by another account + expect(result).toEqual({ + prefetchKey: `${WRITE_COMMANDS.RECONNECT_APP}:123`, + prefetchHeaders: {prefetchKey: `${WRITE_COMMANDS.RECONNECT_APP}:123`}, + }); + }); + + it('does not register a startup prefetch without an account identity', async () => { + // Given the previous account's session has been cleared during sign-out + mockGetAccountID.mockReturnValue(null); + + // When a reconnect request is processed after that cleanup + const preparePrefetchRequest = (await import('@libs/Prefetch/preparePrefetchRequest')).default; + const result = preparePrefetchRequest(WRITE_COMMANDS.RECONNECT_APP); + + // Then it cannot recreate a cross-session prefetch entry + expect(result).toEqual({prefetchKey: undefined, prefetchHeaders: undefined}); + }); +}); From 30987812ddf4bbce53eedbe6e342d0405a47d79c Mon Sep 17 00:00:00 2001 From: chrispader Date: Tue, 28 Jul 2026 11:06:15 +0200 Subject: [PATCH 14/14] fix: spell check --- cspell.json | 1 + 1 file changed, 1 insertion(+) diff --git a/cspell.json b/cspell.json index be5ce9bf80b5..366c7b3a0ab0 100644 --- a/cspell.json +++ b/cspell.json @@ -1075,6 +1075,7 @@ "NitroFetch", "Prefetch", "Prefetcher", + "Autoprefetch", "knip", "lottiefiles", "jsitooling",