Files
CBS-Mobile-App/docs/MULTI_BANK_ARCHITECTURE.md
T
Abin ToppoandClaude Opus 5.5 f960a119e8 Add Positive Pay, statement guide v1.0, onboarding, and switch every feature on
- Positive Pay on the 12 Oct /pps contract (account number, not account ID):
  OTP enrollment, cheque submit, saved cheques, detail and stop payment.
  Full flow passed on the phone against mobile-banking-backend.lincpay.
- Statement & Mini Statement guide v1.0 (14 Oct): mini statement JSON on
  Home and the Statement screen; dated PDF export with the password rule.
- Onboarding slides and splash screen, Inter font, shared Icon and
  TypewriterText components, nearby branches via OpenStreetMap.
- Every feature switched on; the 14 Oct switch set is kept in
  featureConfig.stage-2026-10-14.ts. Loans run on demo data until the
  backend has /loans routes.
- npm run android wraps run-android for shells spawned from VS Code.
- OpenProject work packages updated to 14-10-2026 status, with screenshots
  of Positive Pay, statement, loans and cards.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 18:18:40 +05:30

56 KiB
Raw Blame History

Multi-Bank Architecture

One codebase → one app per bank. Features switch on and off per bank, per role and per customer.

Status Proposal
Applies to bankcbs — React Native 0.83.4 (see ARCHITECTURE.md)
Builds on The feature-flag engine in src/features/ (ARCHITECTURE.md §6)
Audience Mobile team, backend team, QA, product

Contents

  1. The problem
  2. How it works in 60 seconds
  3. The big picture
  4. Terms
  5. What exists today, and the gaps
  6. Target codebase layout
  7. The tenant pack (bank identity)
  8. The feature entitlement model
  9. How it works at runtime
  10. Backend design
  11. Build, distribution and OTA
  12. Which change goes where
  13. Security model
  14. Testing
  15. Migration plan
  16. Adding a new bank
  17. Open questions
  18. Appendix: file map

See also: BANK_FLOW_VARIANTS.md — when a bank needs the same feature to work differently (other steps, other security checks, other rules).


1. The problem

One React Native codebase has to ship as a separate app for each bank (white-label). Every bank:

  • has its own name, logo, colours, package ID, store listing and backend organisation (orgId);
  • buys a different set of features — Bank A has loans and cards, Bank B only payments.

Inside one bank:

  • roles get different apps — customer app vs field-agent app;
  • individual customers get different features — a customer whose KYC is pending cannot apply for a loan; a minor's account cannot send IMPS.

And all of it with the least work per bank and per release.

Requirements

# Requirement
R1 One codebase and one set of screens for every bank
R2 Per-bank branding and identity, without forking code
R3 Per-bank feature set, changeable without a store release
R4 Per-role and per-customer features, decided by the server
R5 When a bank turns a feature off, no lower level (role, customer, device) can turn it back on
R6 Correct offline and before the network answers — never shows a feature the bank does not have
R7 One command releases every bank (store build and OTA)
R8 Adding a new bank takes hours, not weeks
R9 The server enforces everything the app hides — the app is not the security boundary

2. How it works in 60 seconds

  1. Build time — which bank? A tenant pack (tenants/bank-a/) holds the bank's identity: orgId, name, colours, logo, copy, and its default feature set. The build picks one pack per app, so each bank's app contains only its own branding.
  2. App start — what does the bank allow? The app asks GET /features/config?orgId=…. The bank's answer (overrides) is applied and cached. Offline, the last answer is used; on a fresh install offline, the pack's defaults are used.
  3. Sign-in — what does this customer get? After login the same endpoint returns roleOverrides, which the server computes from the customer's role, KYC status, account type and so on.
  4. Resolution. Every screen, tab, button and handler asks useFeature(key). A feature is on only if no bank or session layer turned it off, its own flag is on, and every parent is on. Unknown keys are off.
  5. Delivery. One script builds every bank's store app. JS changes go out by OTA (Hot Updater), one channel per bank.
  6. Same feature, different flow. When a bank needs a feature to work differently — a different value, different security steps, different journey steps, a different rule, a different look — it uses the variation ladder in BANK_FLOW_VARIANTS.md. Shared code never branches on the bank.

3. The big picture

flowchart TB
    subgraph Repo["One repository"]
        Core["Shared core — src/<br/>modules, feature engine, theme"]
        Packs["Tenant packs — tenants/<br/>bank-a, bank-b, …"]
    end

    Repo --> Release["scripts/release.js / CI<br/>loops over tenants/registry.json"]
    Release -->|"store build per bank"| Stores["Play Store / App Store<br/>each bank's own account"]
    Release -->|"JS bundle per bank"| OTA["Hot Updater<br/>channel per bank"]

    Stores --> AppA["Bank A app"]
    Stores --> AppB["Bank B app"]
    OTA --> AppA
    OTA --> AppB

    AppA -->|"config, challenges, APIs"| BEA["Bank A backend<br/>own data, keys, CBS link"]
    AppB -->|"config, challenges, APIs"| BEB["Bank B backend<br/>own data, keys, CBS link"]

    Control["Platform control plane<br/>ops console · catalog · monitoring"] -->|"manages, audited"| BEA
    Control -->|"manages, audited"| BEB

Three things vary, and each has its own mechanism:

What varies Mechanism How a change reaches phones
Identity and branding — name, logo, colours, orgId, package ID Tenant pack, picked at build time OTA for JS/images; store build for icon, home-screen name, package ID
Which features the bank has Tenant pack defaults + server overrides Server: next config fetch. Pack: OTA
Which features this role / customer gets Server roleOverrides after sign-in Next sign-in or config refresh

4. Terms

Term Meaning in this document
Tenant / bank / org One bank on the platform, identified by orgId (today 107295, in env.ts)
Tenant pack The folder tenants/<id>/ holding everything bank-specific inside the app
Feature key A dot path from FeatureKey, e.g. loans.apply. Its parent is derived from the path (loans)
Layer One source of on/off values. Five of them, see §8.2
Kill An authoritative false. Nothing above it can turn the feature back on
Entitlement What the server says one signed-in customer may use
Channel A Hot Updater channel, e.g. bank-a-prod, bank-a-uat

5. What exists today, and the gaps

5.1 Already built — keep it

The hard part — a safe, hierarchical, server-driven feature engine — already exists:

Piece Where What it gives us
Hierarchical keys featureKeys.ts loans off ⇒ every loans.* off
Fail-closed resolution featureService.ts Unknown or misconfigured key ⇒ off
5 ordered layers types.ts:50 default → environment → remote → tenant → user
Authoritative kills types.ts:72 A remote or tenant false beats every higher layer
Server config + offline cache featureRemoteConfig.ts Bank kill survives an offline cold start
Role/customer scope cleared on logout useAppBootstrap.ts:84, appDataPurge.ts:75 One customer's entitlements never leak to the next
Route-level gating featureRoutes.ts, FeatureRoute.tsx A disabled screen is never registered, so deep links cannot reach it
UI and handler gating FeatureGate, useFeature, useFeatureAction, guardFeature No trace of a disabled feature; handlers cannot fire
Unwired-key audit audit-features.js Finds keys nothing consults

5.2 Gaps

# Gap Evidence Fixed in
G1 The bank is hard-coded. orgId, app name, support email, package ID, palette, logo and copy that names "BankCBS" env.ts:35, appConfig.ts:4, build.gradle:82, palette.ts:193, BrandLogo:6, LoginScreen.tsx:536 and others (Appendix) §7
G2 Nothing checks that the app reached its own bank's backend. The config request does not name the bank and the reply is not checked, so a wrong address shows another bank's switches without any error featureRemoteConfig.ts:113 §9.1, §10.6
G3 No per-bank defaults in the app. The environment layer is {}, so a fresh install offline shows the global defaults — possibly features this bank does not have featureConfig.ts:311 §7.4, §8.4
G4 The device-local layer can turn features ON in release builds. The switchboard can be enabled for staff by remote config, and its list includes default-off modules such as cards and loans featureConfig.ts:258, adminFeatures.ts:38 §8.3
G5 No customer-level contract. roleOverrides is described only as "role and tenant" cbs.ts:681 §8.6, §10
G6 Session flags arrive after the main app mounts. The sign-in refresh starts once isAuthenticated is true, and RootNavigator renders the app stack at the same moment, so a customer-disabled tab can flash useAppBootstrap.ts:76, RootNavigator.tsx:89 §9.2
G7 Config is refreshed only at cold start and sign-in. A live kill reaches a customer already in the app only at their next launch or sign-in hydrateFeatures.ts:28, useAppBootstrap.ts:76 §9.3
G8 The startup fetch can hold the splash for 15 s on a slow network — it uses the client's default timeout apiClient.ts:14 §9.1
G9 New keys reach every bank. A new key's default applies to all banks the moment it ships by OTA featureConfig.ts §8.8
G10 One app identity, debug-signed releases. No flavors or schemes; release signs with the debug keystore build.gradle:103 §11
G11 The backend address is edited by hand. API_ORIGIN changes in source, and the Android network config lists ten hosts env.ts, network_security_config.xml §7.4, §10.6
G12 The PIN encryption key travels over plain HTTP before every encryption mpinCrypto.ts:53 §10.6, §13

6. Target codebase layout

CBSBankOriginal/
├── src/                         shared core — never names a bank
│   ├── tenant/                  NEW — the only door to the active pack
│   │   ├── index.ts             export const tenant
│   │   ├── types.ts             TenantConfig contract
│   │   └── tenantGuard.ts       bundle ↔ binary mismatch check
│   ├── features/                existing engine (+ small changes, §8)
│   ├── config/env.ts            orgId / apiOrigin now come from tenant
│   ├── theme/palette.ts         brand roles now come from tenant
│   └── …                        unchanged
├── tenants/                     NEW
│   ├── registry.json            every bank: id, flavor, app IDs, versions
│   ├── _template/               copy this to start a new bank
│   ├── bank-a/
│   │   ├── index.ts             TenantConfig
│   │   ├── features.ts          bank-level feature defaults
│   │   ├── brand.ts             brand colour roles (light + dark)
│   │   ├── copy.ts              bank name inside sentences, legal text
│   │   └── assets/logo.png
│   └── bank-b/ …
├── android/app/src/
│   ├── main/                    shared native code
│   ├── bankA/res/               launcher icons, values/strings.xml (app_name)
│   └── bankB/res/
├── ios/config/                  BankA.xcconfig, BankB.xcconfig, …
├── scripts/release.js           NEW — store build / OTA for one bank or all
└── metro.config.js              resolves '@tenant' → tenants/$TENANT

Where today's values move:

Today Moves to
env.orgId = '107295' tenant.orgId
API_ORIGIN in env.ts tenant.api[APP_ENV]: each bank's own backend, per environment, plus its certificate pins
appConfig.appName, appConfig.supportEmail tenant.displayName, tenant.support.email
brand* and onBrand* keys in palette.ts tenants/<id>/brand.ts (neutrals and semantic tones stay shared)
src/assets/images/logo.jpg tenants/<id>/assets/logo.png
"BankCBS" inside copy tenant.copy.*
applicationId Per flavor. namespace "com.bankcbs.mobile" stays — it is the Kotlin package, not the store ID
ENVIRONMENT_FEATURE_OVERRIDES = {} { ...tenant.features }

The current app becomes the first tenant pack, holding exactly today's values, so the migration causes no visible change.


7. The tenant pack (bank identity)

7.1 Contract

// src/tenant/types.ts
import type { FeatureOverrides } from '../features/types';
import type { Palette } from '../theme/palette';

/** Only the roles that carry the bank's identity. Neutrals, text and semantic tones stay shared. */
export type BrandRoles = Pick<
  Palette,
  | 'brand' | 'brandLight' | 'brandDark' | 'brandDeep' | 'brandSurface' | 'brandBorder'
  | 'brandGradient' | 'heroGradient'
  | 'onBrandPrimary' | 'onBrandSecondary' | 'onBrandMuted'
  | 'onBrandSurface' | 'onBrandSurfaceStrong' | 'onBrandBorder'
>;

export interface TenantConfig {
  /** Stable id. Also the Hot Updater channel prefix: 'bank-a' → 'bank-a-prod'. */
  id: string;
  /** CBS organisation. Sent at registration, sign-in and /features/config. */
  orgId: string;
  /** Home-screen and header name: 'Bank A Mobile'. */
  displayName: string;
  /** Used inside sentences: 'New to Bank A?'. */
  shortName: string;
  /** This bank's own backend, per environment. Never edited by hand in env.ts again. */
  api: { dev: string; uat: string; prod: string };
  /** TLS certificate pins for this bank's backend, per environment. */
  pins: { uat: string[]; prod: string[] };
  support: { email: string; phone: string };
  /** Bank-level feature defaults — the `environment` layer. Sparse. */
  features: FeatureOverrides;
  brand: { light: BrandRoles; dark: BrandRoles };
  /** require('./assets/logo.png') */
  logo: number;
  legal: { termsUrl: string; privacyUrl: string };
}

7.2 Example pack

// tenants/bank-a/index.ts
import type { TenantConfig } from '../../src/tenant/types';
import { brand } from './brand';
import { features } from './features';

const bankA: TenantConfig = {
  id: 'bank-a',
  orgId: '107295',
  displayName: 'Bank A Mobile',
  shortName: 'Bank A',
  api: {
    dev: 'https://dev-api.example.com',
    uat: 'https://uat-api.bank-a.example',
    prod: 'https://api.bank-a.example',
  },
  pins: { uat: ['sha256/…'], prod: ['sha256/…'] },
  support: { email: 'support@bank-a.example.com', phone: '1800-000-000' },
  features,
  brand,
  logo: require('./assets/logo.png'),
  legal: { termsUrl: 'https://bank-a.example.com/terms', privacyUrl: 'https://bank-a.example.com/privacy' },
};

export default bankA;
// tenants/bank-a/features.ts
import { FeatureKey } from '../../src/constants/featureKeys';
import type { FeatureOverrides } from '../../src/features/types';

/**
 * Bank A's product scope. The server can still change any of these live;
 * this is what the app shows before the server has answered.
 */
export const features: FeatureOverrides = {
  [FeatureKey.LOANS]: true,  // bought the lending module
  [FeatureKey.CARDS]: true,  // bought the cards module
  [FeatureKey.AGENT]: false, // no field-agent programme
};

7.3 Picking the pack at build time

Metro maps the import @tenant to tenants/$TENANT. Only that bank's files go into its bundle, so Bank B's app never contains Bank A's logo or config.

// metro.config.js — keeps the existing blockList, adds the tenant resolver
const path = require('path');
const { getDefaultConfig, mergeConfig } = require('@react-native/metro-config');
const exclusionList = require('metro-config/private/defaults/exclusionList').default;

const TENANT = process.env.TENANT;
if (!TENANT) {
  throw new Error('TENANT is not set. Use scripts/release.js or `npm run start:bank-a`.');
}
const tenantDir = path.resolve(__dirname, 'tenants', TENANT);

const config = {
  resolver: {
    blockList: exclusionList([ /* …unchanged… */ ]),
    resolveRequest: (context, moduleName, platform) => {
      if (moduleName === '@tenant' || moduleName.startsWith('@tenant/')) {
        const target = path.join(tenantDir, moduleName.slice('@tenant'.length));
        return context.resolveRequest(context, target, platform);
      }
      return context.resolveRequest(context, moduleName, platform);
    },
  },
};

module.exports = mergeConfig(getDefaultConfig(__dirname), config);

Supporting config:

File Change
tsconfig.json "paths": { "@tenant": ["tenants/_template"], "@tenant/*": ["tenants/_template/*"] }, add "tenants" to include
jest.config.js moduleNameMapper: { '^@tenant$': '<rootDir>/tenants/_template' }
package.json "start:bank-a": "cross-env TENANT=bank-a react-native start --reset-cache", and the same for each bank (cross-env because the team develops on Windows)

Use --reset-cache when switching banks so Metro does not serve the previous bank's resolution.

7.4 How shared code reads the pack

src/tenant/index.ts is the only file that imports @tenant. Everything else imports tenant from it.

// src/tenant/index.ts
import tenantConfig from '@tenant';
export const tenant = tenantConfig;
File Change
env.ts const API_ORIGIN = tenant.api[APP_ENV]; and orgId: tenant.orgId
appConfig.ts appName: tenant.displayName, supportEmail: tenant.support.email
palette.ts export const light: Palette = { ...sharedLight, ...tenant.brand.light }, and the same for dark
BrandLogo const LOGO = tenant.logo;
featureConfig.ts:311 export const ENVIRONMENT_FEATURE_OVERRIDES: FeatureOverrides = { ...tenant.features };
Copy naming "BankCBS" `New to ${tenant.shortName}? ` and so on (list in the Appendix)

Run node scripts/checkContrast.js once per tenant. A bank's brand colour can fail AA where the current blue passes.

7.5 Tenant guard

This stops Bank A's JS from ever running inside Bank B's binary, whether because of a wrong OTA channel or a build run with the wrong TENANT. The binary's Hot Updater channel is baked in at build time (bank-b-prod), so the guard compares the two at startup:

// src/tenant/tenantGuard.ts — called first thing in index.js
import { HotUpdater } from '@hot-updater/react-native';
import { tenant } from './index';

export const assertTenantMatchesBinary = () => {
  if (__DEV__) return;
  const channel = HotUpdater.getDefaultChannel(); // embedded in the binary
  if (!channel.startsWith(`${tenant.id}-`)) {
    throw new Error(`Tenant mismatch: bundle=${tenant.id} binary=${channel}`);
  }
};

A throw before the first render counts as a failed launch, so Hot Updater rolls back to the previous bundle automatically. Until Hot Updater is integrated, use a buildConfigField "String", "TENANT_ID" plus a one-line native module for the same check.


8. The feature entitlement model

8.1 The key tree (existing)

Keys are dot paths. The parent is derived from the path, so the tree cannot drift from the names:

loans
├── loans.dashboard
├── loans.eligibility
├── loans.apply
├── loans.emiPayment
└── loans.foreclosure

A feature is usable only when its own flag is on and every ancestor is on. Disabling loans disables the whole subtree in one switch. See ARCHITECTURE.md §6 for the consumption API (useFeature, FeatureGate, useRouteGuard, useFeatureAction).

8.2 The five layers

The code names stay as they are. This is what each layer means in the multi-bank model:

# Code name Means Decided by Loaded Stored Authoritative kill?
1 default Product defaults Developers, featureConfig.ts Compiled Bundle — (base)
2 environment Bank pack Mobile team, tenants/<id>/features.ts Compiled, per bank bundle Bundle No — the server can override
3 remote Bank live (org-wide) Bank admin portal → overrides Startup, foreground, sign-in Cached on disk Yes
4 tenant Session: role + this customer Server rule engine → roleOverrides Sign-in, foreground Cached; wiped on logout Yes
5 user Local device override QA / staff, FeatureFlagsScreen Startup AsyncStorage No. In release builds it may only turn features off (§8.3)

Why the bank pack is not authoritative: if a bank buys loans later, an admin turns loans on from the portal and it works immediately, with no OTA. The pack is only the safe answer for the moment before the server replies.

Optional clean-up later: rename tenant → session and user → local in FeatureSource. Today's names suggest "the bank" and "the customer", and they are neither.

8.3 Resolution algorithm

flowchart TD
    A["isEnabled(key)"] --> B{"Declared key?"}
    B -- no --> OFF1["OFF — fail closed"]
    B -- yes --> C{"Bank live or session<br/>says false?"}
    C -- yes --> OFF2["OFF — killed"]
    C -- no --> D["Own flag = highest layer that has a value<br/>local* → session → bank live → bank pack → default"]
    D --> E{"Own flag on?"}
    E -- no --> OFF3["OFF"]
    E -- yes --> F{"Every parent on?"}
    F -- no --> OFF4["OFF — blocked by parent"]
    F -- yes --> ON["ON"]

* In release builds a local true is skipped. The local layer may only switch things off.

The only engine change is that release-build rule, in featureService.ts resolveOwnFlagWithSource:

/**
 * Whether the device-local layer may switch a feature ON. Off-only in release:
 * a phone must never widen what its bank and its session allow. Injectable so
 * tests can exercise both modes; UAT builds may opt in (see §11.5).
 */
private localEnableAllowed = __DEV__;

setLocalEnableAllowed(allowed: boolean) {
  this.localEnableAllowed = allowed;
  this.invalidate();
}

// inside the precedence loop, before `return { value: override, source }`:
if (source === FeatureSource.User && override === true && !this.localEnableAllowed) {
  continue; // fall through to the layers below
}

explain() must report locallyToggleable: false for an off row when localEnableAllowed is false. Otherwise the switchboard offers a switch that silently does nothing.

8.4 Bank level

Source Purpose Example
Bank pack (tenants/<id>/features.ts) Correct on first launch and offline; documents the product scope in code review Bank B: loans: false, cards: false
Bank live (overrides) The bank's real-time decision: incidents, launches, contract changes Bank A disables cards during a card-network outage

Rule: the server sends every root key (home, payments, cards, loans, insurance, services, agent, …) explicitly for each bank. That makes the bank's live decision complete rather than relying on whatever the build defaults happen to be.

8.5 Role level

The role comes from the session (AuthSession.role: 'User' | 'Agent', app.ts).

  • RootNavigator picks the app by role; agent: false retires the agent app completely.
  • Role-specific restrictions arrive in roleOverrides, for example agents cannot delete customers: agent.customers.delete: false.

8.6 Customer level

The server computes roleOverrides for this customer, from rules the bank configures:

Rule (evaluated by the server) Sent in roleOverrides
KYC pending loans.apply: false
Minor account / joint account without mandate payments.transfer.imps: false
Device registered less than 24 h ago (cooling period) payments.beneficiaries.add: false
Customer opted in to cards cards: true — only takes effect if the bank has cards
Manual block by branch staff, with expiry any key: false

Contract rules:

  • The session layer can only narrow what the bank allows. The app enforces this (a bank kill beats a session true), and the server never sends a true for something the bank has off.
  • Booleans only. Numeric entitlements (transfer limits, daily caps) belong in a separate typed limits response, not in feature flags.

8.7 Worked example

Setup:

  • Bank A pack: loans: true, cards: true. Bank A live: cards: false (an outage).
  • Bank B pack: loans: false. Bank B live: nothing extra.
  • Customer X (Bank A, KYC pending): session loans.apply: false.
Key Bank A · customer X Bank A · customer Y Bank B · anyone
loans ✅ bank pack ✅ bank pack ❌ bank pack
loans.apply ❌ killed by session ✅ ❌ parent loans off
cards ❌ killed by bank live ❌ killed by bank live ❌ default
Staff phone sets cards: true locally ❌ still killed ❌ still killed ❌ ignored (release: off-only)

8.8 Rules for feature keys

Rule Why How it is enforced
A. The root decides. A module that some banks do not have is switched at its root key; its children default true and are set false only to remove one capability Turning on loans for a bank must turn on the module, not one empty tab One-time cleanup in featureConfig.ts — today loans.* and cards.* children are individually false
B. New keys ship false. An OTA must never show a new feature to banks that did not ask for it (G9) CI check: a key added in the PR with enabled: true fails unless it carries allowDefaultOn with a reason
C. Every key is gated. A switch that gates nothing is a broken kill switch npm run features:audit:strict in CI
D. The backend knows every key. The admin portal must list the same keys the app understands scripts/export-feature-catalog.js writes feature-catalog.json (key, parent, default, description) for the backend to import
E. Never switch sign-in off locally. Locks the phone out of the app Already enforced by adminFeatures.ts

9. How it works at runtime

9.1 Cold start

sequenceDiagram
    autonumber
    participant App as App.tsx
    participant FS as featureService
    participant Disk as AsyncStorage
    participant API as GET /features/config
    participant Nav as RootNavigator

    App->>FS: construct — default + bank pack (compiled, instant)
    App->>Disk: featureOverrides.restore()
    Disk-->>FS: local layer
    App->>Disk: featureRemoteConfig.applyCached()
    Disk-->>FS: bank live (last known)
    App->>API: ?orgId=107295 — no session, 4 s cap
    alt online
        API-->>FS: overrides → bank live layer
        FS->>Disk: write cache
    else offline or timeout
        Note over FS: Last known bank config stays in force.<br/>Fresh install: bank pack defaults.
    end
    App->>Nav: ready = true
    Nav->>Nav: hide splash, mount AuthNavigator with final flags

Changes needed in featureRemoteConfig.ts:112:

/** Long enough for a slow network, short enough not to hold the splash (G8). */
const CONFIG_FETCH_TIMEOUT_MS = 4000;

const fetchConfig = async (): Promise<FeatureConfigResult> => {
  const response = await apiClient.get<FeatureConfigResult>(endpoints.features.config, {
    // Identifies the bank before sign-in (G2). After sign-in the server ignores
    // it and uses the session's org, so a stale value can never cross banks.
    params: { orgId: env.orgId },
    timeout: CONFIG_FETCH_TIMEOUT_MS,
  });

  return response.data;
};

9.2 Sign-in

sequenceDiagram
    autonumber
    actor C as Customer
    participant Login as LoginScreen
    participant API as CBS backend
    participant FS as featureService
    participant Nav as RootNavigator

    C->>Login: MPIN / biometrics
    Login->>API: POST /auth/login (encryptedMpin, orgId)
    API-->>Login: session (token, orgId, role)
    Note over Nav: isAuthenticated = true<br/>sessionFeaturesReady = false → keep splash
    Login->>API: GET /features/config (Bearer token)
    API-->>FS: overrides → bank live, roleOverrides → session
    FS-->>Nav: sessionFeaturesReady = true
    Nav->>Nav: mount MainTabNavigator / AgentNavigator<br/>with bank + role + customer flags

This closes G6. Two ways to do it:

  • Preferred (needs backend): POST /auth/login returns features: { version, overrides, roleOverrides } in its response. The app applies it before setting isAuthenticated. One round trip, and the flags can never arrive late.
  • Interim (app-only): add a sessionFeaturesReady flag to the ui slice. Set it false on sign-in, run the refresh, then set it true in a finally. RootNavigator renders SplashScreen while it is false:
// RootNavigator.tsx
) : isAuthenticated ? (
  sessionFeaturesReady ? renderAppStack() : <SplashScreen />
) : (

If the session refresh fails, the customer gets the bank-level features without customer-level narrowing. Server enforcement (§10.4) still blocks the actions, which is one more reason to prefer the login-response option.

9.3 While the app is open

sequenceDiagram
    autonumber
    participant Admin as Bank admin portal
    participant BE as Config service
    participant App as App (foreground)
    participant FS as featureService
    participant UI as Screens / navigators

    Admin->>BE: cards = false (maker-checker approved), version 43
    Note over App: Customer returns to the app (AppState → active)
    App->>BE: GET /features/config (throttled: at most every 5 min)
    BE-->>FS: overrides v43
    FS-->>UI: version bump → every useFeature re-evaluates
    UI->>UI: Cards tab unregistered (useRouteGuard)
    UI->>UI: Customer on a Cards screen → FeatureRoute navigates away

New hook, mounted in AppShell (App.tsx), closes G7:

// src/features/useFeatureRefresh.ts
import { useEffect, useRef } from 'react';
import { AppState } from 'react-native';

import { env } from '../config/env';
import { featureRemoteConfig } from './featureRemoteConfig';

const MIN_INTERVAL_MS = 5 * 60_000;

/**
 * Re-reads the bank's switches when the app returns to the foreground, so a
 * kill reaches customers who keep the app open — not only at the next launch.
 */
export const useFeatureRefresh = () => {
  const lastRun = useRef(Date.now());

  useEffect(() => {
    const subscription = AppState.addEventListener('change', (state) => {
      if (state !== 'active' || Date.now() - lastRun.current < MIN_INTERVAL_MS) {
        return;
      }
      lastRun.current = Date.now();
      void featureRemoteConfig.refresh({ useMock: env.useMockServer });
    });

    return () => subscription.remove();
  }, []);
};

Ordering note: an authenticated call runs sessionManager.touch() (interceptors.ts:118). Run this refresh after useSessionTimeout has decided whether to lock on return, so that it can never keep a session alive that should have expired.

9.4 Sign-out

Already correct; keep it:

  1. featureRemoteConfig.clearRoleScope() drops the session layer from memory (useAppBootstrap.ts:84).
  2. featureRemoteConfig.forgetRoleScope() deletes it from the disk cache (appDataPurge.ts:75).
  3. The bank live layer stays, because it gates the sign-in screen and a bank kill must survive sign-out.

9.5 Shipping a new feature by OTA

sequenceDiagram
    autonumber
    participant Dev as Developer
    participant OTA as Hot Updater
    participant A as Bank A app
    participant B as Bank B app
    participant Portal as Bank A admin

    Dev->>Dev: add key payments.transfer.newMode — default false (rule B)
    Dev->>OTA: release.js ota all → bank-a-prod, bank-b-prod
    OTA-->>A: new bundle (feature present but OFF)
    OTA-->>B: new bundle (feature present but OFF)
    Dev->>Dev: export-feature-catalog → backend imports new key
    Portal->>Portal: Bank A enables payments.transfer.newMode
    A->>A: next config refresh → feature appears
    Note over B: Bank B never sees it until it opts in

9.6 One tap, five checks

A customer taps Apply for loan:

# Check Code If disabled
1 Route registered? useRouteGuard in LoanNavigator The screen does not exist
2 Button rendered? <FeatureGate feature={FeatureKey.LOANS_APPLY}> Nothing is drawn
3 Handler allowed? useFeatureAction(FeatureKey.LOANS_APPLY, …) No-op, no API call
4 Screen still allowed while open? FeatureRoute Navigates away
5 Server allows it? Entitlement middleware (§10.4) FEATURE_NOT_AVAILABLE error

Checks 1–4 are UX and defence in depth. Check 5 is the real boundary.


10. Backend design

10.1 Endpoint contract

GET /api/v1/features/config?orgId=107295      no session → bank scope only
GET /api/v1/features/config                   with session → bank + session scope
                                              (org taken from the token; orgId param ignored)

Response, inside the standard CbsEnvelope (cbs.ts:673):

{
  "success": true,
  "message": "OK",
  "data": {
    "version": 43,
    "overrides": {
      "home": true, "payments": true, "services": true,
      "loans": true, "cards": false, "insurance": false, "agent": false
    },
    "roleOverrides": {
      "loans.apply": false
    }
  },
  "timestamp": "2026-10-09T10:15:00Z"
}
Rule Reason
roleOverrides is omitted without a session The app then leaves the session layer untouched (already handled in refresh)
Unknown keys are allowed Older app versions drop them (featureRemoteConfig.ts narrow)
version increases per org on every change Logged by the app; ties a support case to the exact config
Authenticated calls use the token's org A customer can never read or mix another bank's config
Optional settings and variants maps ride on the same response Per-bank values and journey choices get the same caching and offline behaviour — see BANK_FLOW_VARIANTS.md §10

10.2 Data model

-- Mirrors FeatureKey + DEFAULT_FEATURE_CONFIG; imported from feature-catalog.json
feature_catalog  (key PK, parent_key, description, default_enabled, added_in_app_version)

-- Bank live layer
org_feature      (org_id, key, enabled, reason, updated_by, updated_at,      PK (org_id, key))
org_config_meta  (org_id PK, version)

-- Session layer inputs
role_feature     (org_id, role, key, enabled,                                 PK (org_id, role, key))
feature_rule     (id, org_id, key, condition, enabled, priority)   -- e.g. condition: kyc_status = 'PENDING'
customer_feature (org_id, customer_id, key, enabled, reason, expires_at, created_by)

-- Every change, for audit and rollback
feature_audit    (id, org_id, scope, scope_ref, key, old_value, new_value, actor, approved_by, at)

10.3 Computing roleOverrides

roleOverrides(session) =
      role_feature[org, role]
    ⊕ feature_rule[org] evaluated against the customer (KYC, account type, device age, segment)
    ⊕ customer_feature[org, customer]            -- unexpired manual entries win
  then, for every key the org has off:  value := false
                                        -- the server never widens the bank

10.4 Server-side enforcement

Every business endpoint is mapped to the feature key that owns it, the same way featureRoutes.ts maps screens:

Endpoint Feature key
POST /fund-transfer/imps/… payments.transfer.imps
POST /payees payments.beneficiaries.add
POST /loans/apply loans.apply

A middleware resolves (org, role, customer, key) with the same algorithm as §8.3 (kills, precedence, ancestors) and rejects with a new error code, FEATURE_NOT_AVAILABLE. Add that code to cbsErrors.ts with authored copy, so the app shows a proper message rather than a generic failure.

10.5 Admin portal (for bank staff)

  • Toggle bank-level features; manage role defaults, customer rules and manual blocks with an expiry.
  • Maker-checker: one person proposes a production change and a second approves it.
  • Every change bumps org_config_meta.version and writes feature_audit.
  • One-click revert to any earlier version.

10.6 Deployment model: one backend per bank

Decided 12 October 2026: one backend codebase, deployed separately for each bank and each environment. This is the same rule as the app.

flowchart LR
    Repo["Backend repo"] --> CI["CI + tests"] --> Image["One image<br/>backend:2.4.0"]
    Image --> AU["Bank A · UAT"] -->|"sign-off"| AP["Bank A · production"]
    Image --> BU["Bank B · UAT"] -->|"sign-off"| BP["Bank B · production"]
    Control["Platform control plane<br/>ops console · catalog · monitoring<br/>no customer data"] -.->|"manages, audited"| AP
    Control -.->|"manages, audited"| BP
Rule Why
One image everywhere. Banks differ only in configuration and secrets: CBS endpoint, database, key pair, certificates, org id A fix is built once and tested once
Each backend serves one bank and refuses any other orgId. The app checks the org id in the first config reply A wrong address or a wrong pack fails loudly instead of showing another bank's data
The config service, flow engine, admin portal and audit log run inside each bank's backend Each bank's settings, approvals and audit trail stay with its own data
The central control plane (ops console, feature catalog, Hot Updater, monitoring) holds no customer data and reaches each backend over a private, audited link Isolation without losing central operations
Each backend reports GET /meta (version, capabilities). Its config service never enables a feature it cannot serve Banks upgrade on their own schedule; the app follows flags, so version differences stay safe
Expand, then contract: additive API and database changes first, removals last The previous backend and older apps keep working during a rollout
At most two backend versions in production across all banks Keeps the compatibility matrix small
Each bank's MPIN/TPIN key pair lives in its own backend. The app already fetches the key before every encryption (mpinCrypto.ts:53), so HTTPS with per-bank certificate pinning is required before production Today the key arrives over plain HTTP and could be swapped on the network

Still open: where each backend runs. Either our cloud, with one isolated account per bank in India, or the bank's data centre, where its operators deploy our signed image. The image and the steps are the same either way.


11. Build, distribution and OTA

11.1 Registry — the single list of banks

tenants/registry.json:

[
  {
    "id": "bank-a", "flavor": "bankA",
    "androidAppId": "com.banka.mobile", "iosBundleId": "com.banka.mobile",
    "appVersion": "1.4.0", "versionCode": 140
  },
  {
    "id": "bank-b", "flavor": "bankB",
    "androidAppId": "com.bankb.mobile", "iosBundleId": "com.bankb.mobile",
    "appVersion": "1.3.2", "versionCode": 132
  }
]

Gradle, the release script and CI all read this file. Adding a bank here creates its flavor automatically.

11.2 Android flavors generated from the registry

// android/app/build.gradle (inside android { … })
def tenants = new groovy.json.JsonSlurper().parse(rootProject.file("../tenants/registry.json"))

namespace "com.bankcbs.mobile"            // Kotlin package — unchanged

flavorDimensions "bank", "env"

signingConfigs {
    debug { /* unchanged */ }
    tenants.each { t ->
        create(t.flavor) {
            // Each bank's upload key comes from CI secrets — never from the repo.
            storeFile file(System.getenv("${t.flavor.toUpperCase()}_KEYSTORE") ?: "missing.keystore")
            storePassword System.getenv("${t.flavor.toUpperCase()}_STORE_PASSWORD")
            keyAlias System.getenv("${t.flavor.toUpperCase()}_KEY_ALIAS")
            keyPassword System.getenv("${t.flavor.toUpperCase()}_KEY_PASSWORD")
        }
    }
}

productFlavors {
    tenants.each { t ->
        create(t.flavor) {
            dimension "bank"
            applicationId t.androidAppId
            versionName t.appVersion
            versionCode t.versionCode
            manifestPlaceholders = [tenant: t.id]
            signingConfig signingConfigs.getByName(t.flavor)
        }
    }
    uat  { dimension "env"; applicationIdSuffix ".uat"; manifestPlaceholders = [env: "uat"] }
    prod { dimension "env"; manifestPlaceholders = [env: "prod"] }
}

buildTypes {
    release {
        // Removed: `signingConfig signingConfigs.debug` — signing now comes from the flavor (G10).
        minifyEnabled enableProguardInReleaseBuilds
        proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro"
    }
}

Per-bank resources override main automatically through Android source sets:

android/app/src/bankA/res/values/strings.xml     <string name="app_name">Bank A Mobile</string>
android/app/src/bankA/res/mipmap-*/ic_launcher*.png

The Hot Updater channel in AndroidManifest.xml is filled in per variant:

<meta-data android:name="com.hotupdater.CHANNEL" android:value="${tenant}-${env}" />

The result is variants such as bankAUatRelease (channel bank-a-uat) and bankAProdRelease (channel bank-a-prod).

11.3 iOS

One xcconfig per bank and environment, e.g. ios/config/BankA-Prod.xcconfig:

PRODUCT_BUNDLE_IDENTIFIER = com.banka.mobile
APP_DISPLAY_NAME = Bank A Mobile
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon-BankA
HOT_UPDATER_CHANNEL = bank-a-prod
TENANT = bank-a
  • Info.plist: CFBundleDisplayName = $(APP_DISPLAY_NAME) and HOT_UPDATER_CHANNEL = $(HOT_UPDATER_CHANNEL).
  • One build configuration (Release-BankA-Prod) and one scheme per bank and environment. Map each configuration in the Podfile: 'Release-BankA-Prod' => :release.
  • Xcode exports build settings as environment variables to the Bundle React Native code and images phase, so Metro sees TENANT with no extra script.

11.4 Release script

// scripts/release.js
// usage: node scripts/release.js <store|ota> <bank-id|all> [prod|uat] [rolloutPercent]
const { execSync } = require('child_process');
const banks = require('../tenants/registry.json');

const [mode, which = 'all', env = 'prod', rollout = '10'] = process.argv.slice(2);
const targets = which === 'all' ? banks : banks.filter((b) => b.id === which);
if (targets.length === 0) throw new Error(`Unknown bank "${which}"`);

const gradlew = process.platform === 'win32' ? 'gradlew.bat' : './gradlew';
const cap = (s) => s.charAt(0).toUpperCase() + s.slice(1);

for (const bank of targets) {
  const run = (cmd, cwd = '.') =>
    execSync(cmd, { cwd, stdio: 'inherit', env: { ...process.env, TENANT: bank.id } });

  if (mode === 'store') {
    run(`${gradlew} bundle${cap(bank.flavor)}${cap(env)}Release`, 'android');
  } else if (mode === 'ota') {
    run(
      `npx hot-updater deploy -p android -c ${bank.id}-${env} -t "${bank.appVersion}" -r ${rollout}`,
    );
  } else {
    throw new Error(`Unknown mode "${mode}"`);
  }
}
Goal Command
JS fix to every bank, 10% rollout node scripts/release.js ota all prod 10
JS fix to Bank B UAT only, 100% node scripts/release.js ota bank-b uat 100
Store build for Bank A node scripts/release.js store bank-a prod

Metro refuses to run without TENANT, and the tenant guard (§7.5) catches a mismatch. That makes the script the only supported way to build.

11.5 Environments and channels

Channel Who installs it Local "turn on" allowed?
bank-a-uat Bank A's testers (internal track / TestFlight) Yes — setLocalEnableAllowed(channel.endsWith('-uat'))
bank-a-prod Bank A's customers No — off-only

Every OTA goes to -uat first. The bank signs off, then the same change goes to -prod with a staged rollout.

11.6 Store accounts

  • Each bank's app is published from the bank's own Google Play and Apple developer accounts, and the bank adds the team as users. Apple's App Review Guideline 4.2.6 rejects template-based apps unless the content provider submits them, and Google Play may flag many near-identical apps published from one account.
  • Use a separate upload keystore per bank, kept in CI secrets.

12. Which change goes where

Change Mechanism Reaches phones Scope
Turn a module off for one bank (incident) Admin portal → bank live Next refresh (≤ 5 min with §9.3) One bank
Restrict one customer Server rule / manual block → session Next sign-in or refresh One customer
A value differs (limit, timeout, PIN length) Bank setting (flow variants §4) Next config refresh One bank
Different security steps (TPIN, OTP, biometric, step-up by amount) Server challenge policy (flow variants §5) Next flow started One bank, amount or customer
Different steps in a journey Journey variant (flow variants §6) Next config refresh (existing variant) / OTA (new one) One bank
Product scope change for a bank Admin portal now; pack later Immediately / next OTA One bank
Bug fix, new screen, copy change OTA to that bank's channels Minutes Per channel
Bank colour or logo tweak Pack change + OTA Minutes One bank
App icon, home-screen name, package ID Store build Days One bank
New native library or permission Store build Days Every bank

13. Security model

Principle Implementation
The server is the boundary Every business endpoint checks the entitlement (§10.4). Flags in the app are UX plus defence in depth
Fail closed Unknown or misconfigured key ⇒ off (existing)
Bank decisions cannot be overridden on the phone Bank-live and session kills are authoritative (existing); local layer is off-only in release (§8.3)
Kill survives offline Last bank config cached and re-applied on cold start (existing)
No cross-customer leakage Session layer wiped from memory and disk on logout (existing)
No cross-bank leakage Per-bank bundle (Metro @tenant), per-bank channel, tenant guard (§7.5), and a separate backend, database and key pair per bank (§10.6)
Config integrity in transit Move to HTTPS before production. API_ORIGIN is plain HTTP today (env.ts), so config could be altered on the network. Optionally the server signs the config payload and the app verifies it
Tampered cache on a rooted phone Can reveal hidden UI, but cannot perform actions — the server rejects them
Code delivery Hot Updater bundle signing; per-bank store signing keys
Accountability Maker-checker and feature_audit in the admin portal
No secrets in packs Packs hold only public values; per-bank API credentials live on the server

14. Testing

Level What Where
Unit — engine New cases: local true ignored when localEnableAllowed is false; bank pack values resolve through environment; bank kill beats session true; explain() marks off rows non-toggleable in release featureService.test.ts
Unit — remote config orgId is sent; timeout falls back to the cache; roleOverrides omitted ⇒ session untouched new featureRemoteConfig.test.ts
Tenant matrix For every bank in registry.json: the pack satisfies TenantConfig, every key in features is a declared FeatureKey, the logo file exists, checkContrast passes with that bank's brand new tenants/tenants.test.ts
Key governance features:audit:strict; new keys default false (rule B) CI
Build assemble<Bank>UatRelease for every bank CI matrix over registry.json
Manual UAT channel per bank; switchboard to exercise flags; the bank signs off before prod QA

15. Migration plan

Estimates are rough and for one developer.

Phase Work Size Visible change
0. Prepare New branch; commit or stash the current work in progress; real release signing ½ day None
1. Harden the engine orgId on /features/config (G2); 4 s fetch timeout (G8); local layer off-only in release (G4); sessionFeaturesReady gate (G6); useFeatureRefresh (G7); tests 1–2 days None
2. Tenant pack src/tenant/, tenants/_template, first pack with today's values (org 107295); Metro resolver, tsconfig, jest, npm scripts; move env, appConfig, palette, logo, copy; ENVIRONMENT_FEATURE_OVERRIDES from the pack (G1, G3); rule A cleanup in featureConfig.ts 2–3 days None — same bank, same look
3. Build and distribution registry.json; Android flavors from the registry; iOS xcconfigs and schemes; release.js; per-bank signing; CI matrix (G10) 2–3 days Package IDs per bank
4. Backend Catalog import, org / role / customer tables, rule engine, admin portal with maker-checker, enforcement middleware, FEATURE_NOT_AVAILABLE, features in the login response Backend team Live per-bank control
5. OTA per bank Hot Updater with <bank>-<env> channels, tenant guard, bundle signing 1 day OTA per bank
6. Second bank Follow §16 ½ day New app
F1–F6. Flow variations Settings, challenge framework, transfer and other security flows on it, backend challenge protocol — see BANK_FLOW_VARIANTS.md §14. F1–F4 can run alongside phases 1–3 7–9 days + backend None for the current bank

16. Adding a new bank

# Step Who
1 Copy tenants/_template → tenants/bank-c; fill in index.ts, features.ts, brand.ts, copy.ts, logo Mobile
2 Run node scripts/checkContrast.js with Bank C's brand; adjust until it passes AA Mobile
3 Add Bank C to tenants/registry.json — flavor, app IDs, version Mobile
4 Android: android/app/src/bankC/res/ — launcher icons, values/strings.xml Mobile
5 iOS: BankC-Uat.xcconfig, BankC-Prod.xcconfig, configurations, schemes, AppIcon-BankC Mobile
6 Add Bank C's keystore secrets to CI DevOps
7 Create the org, its bank-level features and customer rules in the admin portal Backend / bank
8 The bank creates its Play Console / App Store Connect listing and adds the team Bank
9 node scripts/release.js store bank-c uat → bank UAT sign-off → prod Mobile + bank
10 From now on, Bank C gets JS updates via bank-c-uat / bank-c-prod —

17. Open questions

# Question Affects
1 One shared backend for all banks, or one per bank? Decided 12 Oct 2026: one backend codebase, deployed separately per bank and environment. Still open: where each backend runs (our cloud or the bank's data centre) tenant.api, §10.6
2 Does every bank have its own Play and Apple developer account? §11.6
3 Who operates the admin portal — bank staff or our support team? Is maker-checker required? §10.5
4 Which customer rules are needed (KYC, account type, device age, segment)? §8.6, §10.3
5 Can /auth/login return the feature config? §9.2
6 For customer-level restrictions, should the app hide the feature (current design: no trace) or show a reason with an action ("Complete KYC to apply")? Hiding suits a bank that has no loans product; a customer who could qualify may need the prompt FeatureGate design
7 Where do per-bank numeric limits come from (transfer caps, daily limits)? §8.6
8 Do all banks release on the same version, or each on its own schedule? registry.json versions, OTA targeting
9 Will the backend adopt the generic challenge protocol (flowId + /flows/{id}/answer) so security steps become per-bank policy? BANK_FLOW_VARIANTS.md §5

Appendix: file map

File Phase Change
src/features/featureRemoteConfig.ts 1 orgId param, fetch timeout
src/features/featureService.ts 1 localEnableAllowed, off-only rule, explain()
src/features/featureService.test.ts 1 New cases (§14)
src/features/useFeatureRefresh.ts 1 New — foreground refresh
src/hooks/useAppBootstrap.ts 1 sessionFeaturesReady around the sign-in refresh
src/navigation/RootNavigator.tsx 1 Hold the app stack until session flags land
App.tsx 1 Mount useFeatureRefresh
src/tenant/ 2 New — index.ts, types.ts, tenantGuard.ts
tenants/ 2 New — _template, first pack, registry.json
metro.config.js 2 @tenant resolver
tsconfig.json, jest.config.js, package.json 2 Path mapping, mapper, per-bank scripts
src/config/env.ts 2 orgId, API_ORIGIN from tenant
src/config/appConfig.ts 2 appName, supportEmail from tenant
src/theme/palette.ts 2 Brand roles from tenant
src/modules/auth/components/BrandLogo/index.tsx 2 Logo from tenant
src/features/featureConfig.ts 2 ENVIRONMENT_FEATURE_OVERRIDES from tenant; rule A cleanup
Copy naming "BankCBS": LoginScreen.tsx:536, AwarenessDetailScreen.tsx:36,105, TransferToOwnAccountScreen.tsx:90, SchemeDetailScreen.tsx:35, SchemeEnrollmentScreen.tsx:188, PaymentMethodTile.tsx:16, osmLocator.ts:32 2 tenant.shortName / tenant.copy
android/app/build.gradle 3 Flavors and signing from the registry; drop debug signing on release
android/app/src/<bank>/res/ 3 Icons, app_name
android/app/src/main/AndroidManifest.xml 3, 5 Hot Updater channel placeholder
ios/config/*.xcconfig, Info.plist, Podfile 3 Per-bank configurations
scripts/release.js, scripts/export-feature-catalog.js 3 New
src/services/api/cbsErrors.ts 4 FEATURE_NOT_AVAILABLE
src/types/cbs.ts 4 Document roleOverrides as session entitlements; login response features