# BankCBS — Mobile Application Architecture A React Native mobile banking client for a Core Banking System (CBS) backend. It serves **two distinct applications from one binary** — a retail customer app and a field-agent app — selected at runtime by the authenticated user's role, with every navigable route, tab and control individually switchable through a hierarchical feature-flag engine. | | | |---|---| | **Package** | `bankcbs` v1.0.0 (`com.bankcbs.mobile`) | | **Runtime** | React Native 0.83.4, React 19.2, TypeScript 5.9 (`strict`, `noUncheckedIndexedAccess`) | | **State** | Redux Toolkit 2.11 + RTK Query, `react-redux` 9.2 | | **Navigation** | React Navigation 7 (native-stack + bottom-tabs) | | **Transport** | Axios 1.14 wrapped by a custom RTK Query `baseQuery` | | **Crypto / secrets** | `node-forge` (RSA-OAEP), `react-native-keychain`, `react-native-biometrics` | | **Scale** | 381 source files · ~58,300 LOC · 103 screens · 17 modules · 182 feature keys | --- ## 1. Layering Dependencies flow strictly downward. Nothing in a lower layer imports from a higher one. ``` ┌───────────────────────────────────────────────────────────────────┐ │ App.tsx / index.js composition root, provider order │ ├───────────────────────────────────────────────────────────────────┤ │ src/navigation/ role routing + feature-gated routes │ ├───────────────────────────────────────────────────────────────────┤ │ src/modules// 17 self-contained domain modules │ │ screens · components · hooks · store · services · types · utils│ ├───────────────────────────────────────────────────────────────────┤ │ src/features/ feature-flag engine (cross-cutting) │ │ src/components/ 16 shared presentational primitives │ │ src/theme/ design tokens + ThemeProvider │ │ src/hooks/ app-wide hooks (bootstrap, session…) │ ├───────────────────────────────────────────────────────────────────┤ │ src/services/ api · security · storage · device … │ │ src/app/ store, rootReducer, typed hooks │ │ src/config/ constants/ types/ utils/ │ └───────────────────────────────────────────────────────────────────┘ ``` ### Directory map | Path | Responsibility | |---|---| | [src/app/](src/app/) | Store configuration, `rootReducer`, typed `useAppDispatch`/`useAppSelector` | | [src/navigation/](src/navigation/) | Root/tab/stack navigators, param lists, route→feature maps | | [src/modules/](src/modules/) | Domain modules (see §7) | | [src/features/](src/features/) | Feature-flag service, provider, gates, hooks | | [src/services/api/](src/services/api/) | Axios client, interceptors, endpoint registry, error catalogue | | [src/services/security/](src/services/security/) | Session manager, device identity, MPIN crypto, biometrics | | [src/services/storage/](src/services/storage/) | Keychain/AsyncStorage-backed secure storage | | [src/components/](src/components/) | Shared UI primitives, barrel-exported | | [src/theme/](src/theme/) | `FintechTheme` token contract + context | | [src/constants/](src/constants/) | Feature keys, route names, RTK Query tags, translations, fixtures | | [src/types/](src/types/) | `api.ts` (client errors), `app.ts` (domain), `cbs.ts` (wire types) | --- ## 2. Composition root [App.tsx](App.tsx) fixes the provider order, and the order is load-bearing: ``` GestureHandlerRootView └── SafeAreaProvider └── redux Provider ← store must exist before features read session └── FeatureProvider ← flags must resolve before navigators register routes └── ThemeProvider └── AppShell ├── useAppBootstrap() └── ActivityTracker (touch capture → session keep-alive) ├── RootNavigator └── ToastHost ``` [index.js](index.js) handles three pre-React concerns: 1. `react-native-get-random-values` is imported **first** — `uuid` binds `crypto.getRandomValues` at module load, and both the device ID and the MPIN nonce import `uuid`, so a later import throws at startup. 2. `notifee.onBackgroundEvent` is registered (required by Notifee even as a no-op). 3. `Text` / `TextInput` `defaultProps` are patched to the `Inter-Regular` font family. ### Bootstrap sequence [src/hooks/useAppBootstrap.ts](src/hooks/useAppBootstrap.ts) runs once on mount: 1. `bootstrapAuthSession()` — mints the device ID and **validates any stored session against the server** before trusting it. 2. `seedAllData()` — populates the fixture-backed modules (profile, dashboard, beneficiaries, payees, deposits, service requests, loans, insurance, cards). 3. `markAppReady()`, then `registerForPushNotifications()`. 4. Registers a global unauthorized handler that dispatches `logoutSession()` and raises a toast. It also re-seeds on every sign-in transition, because logout wipes the entire Redux tree (§4), and on sign-*out* re-reads only the sign-in context — a full bootstrap would raise `isHydrating` and blank the app behind a loader on the way out. --- ## 3. Navigation architecture ``` RootNavigator ← isHydrating? Loader : isAuthenticated? ├── AuthNavigator (unauthenticated) │ Login · Register · VerifyOtp · CreateMpin · MpinLocked ├── MainTabNavigator (role: 'User') │ ├── Home → HomeNavigator (+ nested LoanFlow, InsuranceFlow) │ ├── Payments → PaymentsNavigator │ ├── Services → ServicesNavigator (Hub; tabPress resets the stack) │ ├── Cards → CardsNavigator │ └── Profile → ProfileNavigator (lives in src/modules/profile/navigation/) └── AgentNavigator (role: 'Agent') ├── AgentDashboard · AgentCustomers · AgentCollections └── AgentLoans · AgentProfile (each a nested native stack) ``` Three deliberate decisions are documented in the code: - **Role switch, not a route.** [RootNavigator.tsx](src/navigation/RootNavigator.tsx) picks the app stack from `auth.role`. Disabling `FeatureKey.AGENT` retires the whole field-agent app; an already-signed-in agent falls back to the customer app rather than landing on an empty navigator. - **Initial route computed, never navigated to.** [AuthNavigator.tsx](src/navigation/AuthNavigator.tsx) derives `initialRouteName` from a stored profile instead of `replace()`-ing in an effect — an effect-driven replace races navigation-container readiness and is silently dropped, and the warning that would reveal it is stripped from release builds. `signInContextLoaded` gates this so a null profile means "unknown" rather than "never registered" before the Keystore has been read. - **`CreateMpin` and `MpinLocked` disable the back gesture.** The setup token is single-use; leaving halfway means restarting registration. ### Route-level feature gating [src/navigation/featureRoutes.ts](src/navigation/featureRoutes.ts) maps every route name to the `FeatureKey` that owns it, and each navigator wraps its `Screen` in `canRoute(...)`: ```tsx const canRoute = useRouteGuard(CARDS_ROUTE_FEATURES); {canRoute('CardDetails') && } ``` A disabled route is **never registered**, so it cannot be reached by `navigate()`, a deep link, or restored navigation state. Hiding the menu entry alone would not achieve that. [FeatureRoute.tsx](src/navigation/FeatureRoute.tsx) is a secondary screen-level guard for defence in depth. --- ## 4. State management [src/app/store.ts](src/app/store.ts) · [src/app/rootReducer.ts](src/app/rootReducer.ts) 16 domain slices plus two RTK Query reducers (`baseApi`, `schemesApi`). `serializableCheck` is disabled. **Logout wipes everything.** `rootReducer` intercepts `auth/logoutSession/fulfilled` and sets `state = undefined`, so no slice can leak data across sessions. Re-seeding on sign-in (§2) is the counterpart to this. Three coexisting data-access patterns: | Pattern | Where | Used by | |---|---|---| | `baseApi.injectEndpoints` (RTK Query over Axios) | `modules/*/services/*Api.ts` | agent, cards, deposit, home ×2, insurance, loan, payments, profile, services ×2, transfer | | `createSlice` + `createAsyncThunk` | `modules/*/store/` | auth, cards, deposit, home, insurance, loan, payments, profile, services, transfer, ui | | Standalone `createApi` | `modules/schemes/services/schemesApi.ts` | schemes only — its own reducer path, fixture `queryFn`s | Cache invalidation runs on a central tag list — [src/constants/queryTags.ts](src/constants/queryTags.ts), 25 tags — registered on `baseApi` so any module can invalidate another's cache. --- ## 5. Data & API layer ### The envelope Every CBS response shares one wrapper ([src/types/cbs.ts](src/types/cbs.ts)): ```ts interface CbsEnvelope { success: boolean; message: string; data?: T; // present on success; VALIDATION_ERROR carries a field map here errorCode?: string; // only on failure — this is what the app switches on timestamp: string; } ``` ### Interceptors [src/services/api/interceptors.ts](src/services/api/interceptors.ts) is where the contract is enforced: - **Request:** attaches `Authorization: Bearer ` from `secureStorage`. - **Response:** branches on `envelope.success`, **not the HTTP status** — a failure can arrive with a 2xx, and treating it as a payload hands callers `undefined`. Failures are thrown as a `CbsApiError`; successes are unwrapped so callers read `response.data` as the payload. - **Session mirroring:** any call carrying an `Authorization` header calls `sessionManager.touch()`, because that same call reset the server's idle timer. - **Session loss:** only `SESSION_INVALID` clears storage and fires the global unauthorized handlers. `INVALID_CREDENTIALS` shares the 401 but means a wrong MPIN on the sign-in form — bouncing the user out of a screen they aren't in yet would be nonsense. ### Error catalogue [src/services/api/cbsErrors.ts](src/services/api/cbsErrors.ts) enumerates the **31 backend error codes** as a `const` tuple, plus 3 client-synthesised codes (`NETWORK_ERROR`, `REQUEST_TIMEOUT`, `UNKNOWN_ERROR`). Each has authored `{ title, description, retryable }` copy, so no failure can render as a blank toast — asserted by [cbsErrors.test.ts](src/services/api/cbsErrors.test.ts). `errorCode` is treated as the stable contract; `message` is assumed re-wordable server-side, so no branch reads the message or the status. ### Endpoint registry & mock strategy All paths live in one object, [src/services/api/endpoints.ts](src/services/api/endpoints.ts), with parameterised routes as functions. `resolveWithMock` in [serviceHelpers.ts](src/services/api/serviceHelpers.ts) returns fixtures when `appConfig.features.useMockServer` is set. Per its own contract, endpoints the CBS API actually implements **must not** go through it — they call `apiClient` directly so failures surface rather than being papered over with demo data. Live today: registration, sign-in, accounts/MMID, payees. Fixture-backed: dashboard, cards, loans, insurance, deposits, service requests, the agent app. --- ## 6. Feature-flag engine The most distinctive part of the architecture: **182 keys** covering every module, screen, tab, menu row, button and action. ### Keys encode the hierarchy [src/constants/featureKeys.ts](src/constants/featureKeys.ts) declares a dot-notation enum; the service derives parent/child from the path itself, so the tree can never drift from the naming. ``` loans → loans.apply → loans.apply.submit ``` [featureConfig.ts](src/features/featureConfig.ts) is a flat `Record` **on purpose** — the `Record` makes TypeScript reject the file if any declared key lacks config, which a nested object literal cannot guarantee. ### Resolution [featureService.ts](src/features/featureService.ts) — one instance, holding a registry, a layer stack, a resolution cache, and a listener set: ``` default → environment → remote → tenant → user (lowest → highest priority) ``` A feature is enabled only if it exists, its own flag is on, **and every ancestor resolves enabled**. Resolution is **fail-closed**: an unknown key, or a key with missing config, is `false` — in a banking app an unrecognised flag must hide the feature, never reveal it. `validate()` runs in `__DEV__` and reports `missing-config`, `unknown-key` and `missing-parent` issues, catching a typo like `loans.aply.submit` that would otherwise become a root feature no parent could switch off. ### Consumption surface | API | Use | |---|---| | `useFeature(key)` / `useFeatures([...])` | reactive boolean(s); subscribes via `useSyncExternalStore` on a version counter | | `` | renders children or **nothing** — deliberately no `fallback` prop, so a disabled feature leaves no trace: no greyed control, no "coming soon", no hidden-but-present node | | `` | JSON-driven UI where the key arrives as data | | `useFeatureAction` / `guardFeature` | guards a *handler* some other path may still hold | | `useRouteGuard(map)` | navigator-level route registration (§3) | | `FeatureProvider` | resolves async remote/tenant layers at startup, exposes `ready` | `FeatureProvider` swallows loader failures so the app always falls back to compiled-in defaults rather than blocking startup. Remote and tenant loaders are optional props — nothing is wired to Firebase Remote Config or a config API yet, but the seam exists without touching a single UI component. --- ## 7. Module anatomy Every module under [src/modules/](src/modules/) follows the same internal shape, so any module is navigable once one of them is: ``` modules// ├── screens/ route components ├── components/ domain-specific presentational pieces ├── hooks/ form/flow logic extracted from screens ├── store/ Slice.ts · Thunk.ts · Selectors.ts ├── services/ Api.ts (baseApi.injectEndpoints) ├── types/ domain types ├── utils/ calculators, validators, fixtures ├── constants/ domain constants (agent, awareness, schemes) └── navigation/ only where the module owns its own navigator (agent, profile) ``` | Module | Screens | Components | Store | Services | Hooks | Files | |---|---:|---:|---:|---:|---:|---:| | agent | 18 | 9 | 2 | 1 | 5 | 41 | | services | 13 | 6 | 3 | 2 | 3 | 30 | | profile | 12 | 5 | 3 | 1 | 1 | 25 | | insurance | 10 | 9 | 3 | 2 | 2 | 29 | | loan | 10 | 11 | 3 | 1 | 4 | 33 | | home | 7 | 7 | 3 | 2 | 1 | 22 | | payments | 7 | 1 | 4 | 1 | 1 | 17 | | schemes | 7 | 5 | 2 | 1 | 1 | 18 | | auth | 5 | 7 | 3 | 1 | 1 | 19 | | deposit | 5 | 4 | 3 | 1 | 2 | 19 | | cards | 4 | 9 | 3 | 1 | 2 | 21 | | awareness | 2 | 1 | — | — | 1 | 6 | | notifications | 1 | — | 1 | — | — | 2 | | search | 1 | — | — | — | — | 2 | | employee | 1 | — | — | — | — | 1 | | transfer | — | 1 | 3 | 1 | 1 | 8 | | ui | — | — | 3 | — | 1 | 4 | `transfer` is state-and-service only (its screens live under `payments`); `ui` holds cross-cutting UI state (toasts, app-ready, session-expired). --- ## 8. Security & session architecture ### MPIN never leaves the device in the clear [mpinCrypto.ts](src/services/security/mpinCrypto.ts) encrypts `{ mpin, nonce, ts }` under the server's RSA public key (**RSA-OAEP, SHA-256, MGF1 explicitly SHA-256**) and sends it as `encryptedMpin`. Three integration hazards are called out in the source, all of which fail silently: - MGF1 **must** be passed explicitly. node-forge happens to default it to `md`, but Java's `OAEPWithSHA-256AndMGF1Padding` leaves MGF1 on SHA-1 — relying on a default produces ciphertext the server cannot decrypt, with a useless error. - `ts` is in **seconds**. Milliseconds read as ~55,000 years ahead and are rejected. - `nonce` must be fresh per call or it is refused as a replay. The key is fetched from `/crypto/public-key` and cached; `PINNED_PUBLIC_KEY` is a fallback for when the fetch fails. `invalidate()` drops the cache after a `MALFORMED_REQUEST`, which is what a stale key looks like client-side (the server cannot say "your key is wrong" without leaking whether the profile exists). ### Device binding [deviceIdentity.ts](src/services/security/deviceIdentity.ts) mints one UUID on first launch, persisted to the Keychain under `ACCESSIBLE.AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY` (not `WHEN_UNLOCKED_*` — the ID is not a secret and sign-in must read it *before* the user authenticates). It is memoised in memory, and concurrent cold-start callers share one in-flight promise — otherwise each would mint a UUID, last write wins, and the device silently unbinds. The registered profile is bound to this value; a mismatch returns `DEVICE_NOT_BOUND`, which reads like a server fault but is a client bug. ### Secure storage [secureStorage.ts](src/services/storage/secureStorage.ts) keeps three Keychain services — session, last profile, biometric MPIN — plus a biometric-enabled flag in AsyncStorage (the device ID is a fourth, owned by `deviceIdentity`). The session is cached in memory because the request interceptor needs the token on *every* call and an Android Keystore read costs tens of ms. The MPIN is retained **only** when the customer opts into biometric sign-in: the API has no refresh token, so a session can only be opened by encrypting the MPIN, which makes biometric sign-in impossible otherwise. It sits behind the device's own biometric prompt, never in AsyncStorage. An unparseable session (written by an older build) is treated as absent rather than crashing bootstrap. ### Session lifetime — three independent triggers [sessionManager.ts](src/services/security/sessionManager.ts) adopts the server's own limits at sign-in (`idleTimeoutSeconds`, `expiresAt`) so the app never guesses and never drifts from what the backend enforces. It tracks **two clocks separately**: - `lastServerContactAt` — the only thing the server's idle window is measured from, so the only thing expiry may be judged on. - `lastInteractionAt` — screen touches. Because most of the app reads from fixtures, a user can tap around for five minutes without one authenticated call; treating that as idle would sign them out mid-use. So interaction drives a **keep-alive**, never the expiry clock itself. [useSessionTimeout.ts](src/hooks/useSessionTimeout.ts) polls every 5s and expires on whichever of three triggers fires first — `idle`, `absolute`, or `background` (returning to the foreground after `lockAfterBackgroundMs`, default 30s). Each has its own copy; `background` is toned as informational because a deliberate re-lock is not a failure, and dressing it up as one would train users to ignore the banner that matters. [ActivityTracker](src/components/ActivityTracker/) wraps the whole tree and reads touches via `onStartShouldSetResponderCapture`, returning `false` so it sees every touch on the way down but never becomes the responder — a wrapper that actually captured touches would break every pressable beneath it. `appConfig.security.requireMpinOnLaunch` defaults to **true**: every launch asks for the MPIN rather than resuming a stored session, because there is no local MPIN verifier — the only one is `POST /auth/login`, so "ask again" necessarily means "open a fresh session". --- ## 9. Presentation layer **Theme.** [src/theme/](src/theme/) defines a `FintechTheme` token contract — colours (including a "Cyber Nexus" accent set), spacing, typography scale, radii, shadows — consumed via `useTheme()`. `RootNavigator` maps these onto React Navigation's `DarkTheme`. **Shared primitives.** 16 barrel-exported components in [src/components/](src/components/): `Button`, `Input`, `Card`, `Modal`, `BottomSheet`, `DatePicker`, `Loader`, `EmptyState`, `BalanceCard`, `TransactionItem`, `AppHeader`, `ToastHost`, `ActivityTracker`, plus three keyboard/layout containers. **`ScreenShell`** is the standard screen wrapper — gradient background, safe area, `AppHeader`, optional scroll, per-platform `KeyboardAvoidingView` behaviour (`padding` on iOS, `height` on Android), `withTabBar` padding so content never hides behind the absolutely-positioned tab bar, and a `fixedFooter` slot. **i18n.** [useTranslation.ts](src/hooks/useTranslation.ts) resolves keys against [translations.ts](src/constants/translations.ts) using the language in the profile slice, falling back to `en` then to the key itself. Currently a small dictionary rather than a full i18n library. **Notifications.** [pushNotifications.ts](src/services/notifications/pushNotifications.ts) (Notifee) for delivery; the `notifications` slice holds the in-app inbox. --- ## 10. Build & platform - **Android permissions:** `INTERNET`, fine/coarse location (branch locator, agent routes), `POST_NOTIFICATIONS`, `READ_PHONE_STATE` / `READ_PHONE_NUMBERS` (SIM-card checks in [simCard.ts](src/services/device/simCard.ts)). - **Metro** ([metro.config.js](metro.config.js)) block-lists native build output — `android/app/build` alone exceeds 2 GB after a debug build, and without watchman Metro walks it on startup and never becomes responsive. - **TypeScript** is `strict` with `noUncheckedIndexedAccess` and `bundler` module resolution. - **Scripts:** `start`, `android`, `ios`, `lint`, `test`, `typecheck`, `clean:android`. - **Notable native deps:** MapLibre (branch locator / agent routes), Reanimated 4 + Worklets, gesture-handler, `@gorhom/bottom-sheet`, SVG, linear-gradient, haptic feedback, date-picker. --- ## 11. Testing Jest, with 5 unit suites concentrated on the logic where a silent error is costly: | Suite | Covers | |---|---| | [featureService.test.ts](src/features/featureService.test.ts) | flag resolution, hierarchy, layer precedence, fail-closed behaviour | | [cbsErrors.test.ts](src/services/api/cbsErrors.test.ts) | all 31 codes present and unique, copy for every code, retryability, `CbsApiError` identity | | [validation.test.ts](src/utils/validation.test.ts) | shared input validators | | [emi.test.ts](src/modules/loan/utils/emi.test.ts) | loan EMI maths | | [depositCalculators.test.ts](src/modules/deposit/utils/depositCalculators.test.ts) | FD/RD maturity maths | There is no component, navigation or integration test layer. --- ## 12. Current state & known gaps Factual observations from the code, not recommendations: 1. **`useMockServer` is `true`** in [src/config/env.ts](src/config/env.ts). Registration, sign-in, accounts/MMID and payees hit the live API; the other modules resolve from ~1,390 lines of fixtures in [src/constants/mockData.ts](src/constants/mockData.ts). 2. **`API_ORIGIN` is a hard-coded LAN address** (`http://192.168.6.138:8079`) over plain HTTP. The file documents that debug builds permit cleartext and release builds will need HTTPS or a network-security-config. There is no per-environment config mechanism. 3. **`PINNED_PUBLIC_KEY` is committed in source** as the fallback when the key fetch fails. 4. **`schemesApi` bypasses the shared stack** — its own `createApi` with `fetchBaseQuery({ baseUrl: '/' })` and fixture `queryFn`s, including a hard-coded `otp !== '123456'` check. It does not use `baseApi`, the endpoint registry, or the CBS error catalogue. 5. **Remote and tenant flag layers are unwired.** `ENVIRONMENT_FEATURE_OVERRIDES` is `{}` and `FeatureProvider` is mounted without loader props, so all 182 keys resolve from the compiled-in defaults. The layering exists as a seam. 6. **Three data-access patterns coexist** (RTK Query, slice+thunk, standalone `createApi`), several modules using two of them at once. 7. **`serializableCheck` is disabled** store-wide. 8. **The `employee` module is a single placeholder screen**; `EmployeeNavigator` is defined but not reachable from `RootNavigator`, which routes only `'Agent'` and `'User'`.