- 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>
1099 lines
56 KiB
Markdown
1099 lines
56 KiB
Markdown
# 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](../ARCHITECTURE.md)) |
|
||
| **Builds on** | The feature-flag engine in [src/features/](../src/features/) (ARCHITECTURE.md §6) |
|
||
| **Audience** | Mobile team, backend team, QA, product |
|
||
|
||
---
|
||
|
||
## Contents
|
||
|
||
1. [The problem](#1-the-problem)
|
||
2. [How it works in 60 seconds](#2-how-it-works-in-60-seconds)
|
||
3. [The big picture](#3-the-big-picture)
|
||
4. [Terms](#4-terms)
|
||
5. [What exists today, and the gaps](#5-what-exists-today-and-the-gaps)
|
||
6. [Target codebase layout](#6-target-codebase-layout)
|
||
7. [The tenant pack (bank identity)](#7-the-tenant-pack-bank-identity)
|
||
8. [The feature entitlement model](#8-the-feature-entitlement-model)
|
||
9. [How it works at runtime](#9-how-it-works-at-runtime)
|
||
10. [Backend design](#10-backend-design)
|
||
11. [Build, distribution and OTA](#11-build-distribution-and-ota)
|
||
12. [Which change goes where](#12-which-change-goes-where)
|
||
13. [Security model](#13-security-model)
|
||
14. [Testing](#14-testing)
|
||
15. [Migration plan](#15-migration-plan)
|
||
16. [Adding a new bank](#16-adding-a-new-bank)
|
||
17. [Open questions](#17-open-questions)
|
||
18. [Appendix: file map](#appendix-file-map)
|
||
|
||
**See also:** [BANK_FLOW_VARIANTS.md](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](BANK_FLOW_VARIANTS.md). Shared code never branches on the bank.
|
||
|
||
---
|
||
|
||
## 3. The big picture
|
||
|
||
```mermaid
|
||
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](../src/config/env.ts)) |
|
||
| **Tenant pack** | The folder `tenants/<id>/` holding everything bank-specific inside the app |
|
||
| **Feature key** | A dot path from [`FeatureKey`](../src/constants/featureKeys.ts), 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](../src/constants/featureKeys.ts) | `loans` off ⇒ every `loans.*` off |
|
||
| Fail-closed resolution | [featureService.ts](../src/features/featureService.ts) | Unknown or misconfigured key ⇒ off |
|
||
| 5 ordered layers | [types.ts:50](../src/features/types.ts) | `default → environment → remote → tenant → user` |
|
||
| Authoritative kills | [types.ts:72](../src/features/types.ts) | A `remote` or `tenant` `false` beats every higher layer |
|
||
| Server config + offline cache | [featureRemoteConfig.ts](../src/features/featureRemoteConfig.ts) | Bank kill survives an offline cold start |
|
||
| Role/customer scope cleared on logout | [useAppBootstrap.ts:84](../src/hooks/useAppBootstrap.ts), [appDataPurge.ts:75](../src/services/storage/appDataPurge.ts) | One customer's entitlements never leak to the next |
|
||
| Route-level gating | [featureRoutes.ts](../src/navigation/featureRoutes.ts), [FeatureRoute.tsx](../src/navigation/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](../scripts/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](../src/config/env.ts), [appConfig.ts:4](../src/config/appConfig.ts), [build.gradle:82](../android/app/build.gradle), [palette.ts:193](../src/theme/palette.ts), [BrandLogo:6](../src/modules/auth/components/BrandLogo/index.tsx), [LoginScreen.tsx:536](../src/modules/auth/screens/LoginScreen.tsx) 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](../src/features/featureRemoteConfig.ts) | §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](../src/features/featureConfig.ts) | §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](../src/features/featureConfig.ts), [adminFeatures.ts:38](../src/features/adminFeatures.ts) | §8.3 |
|
||
| G5 | **No customer-level contract.** `roleOverrides` is described only as "role and tenant" | [cbs.ts:681](../src/types/cbs.ts) | §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](../src/hooks/useAppBootstrap.ts), [RootNavigator.tsx:89](../src/navigation/RootNavigator.tsx) | §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](../src/features/hydrateFeatures.ts), [useAppBootstrap.ts:76](../src/hooks/useAppBootstrap.ts) | §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](../src/services/api/apiClient.ts) | §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](../src/features/featureConfig.ts) | §8.8 |
|
||
| G10 | **One app identity, debug-signed releases.** No flavors or schemes; `release` signs with the debug keystore | [build.gradle:103](../android/app/build.gradle) | §11 |
|
||
| G11 | **The backend address is edited by hand.** `API_ORIGIN` changes in source, and the Android network config lists ten hosts | [env.ts](../src/config/env.ts), [network_security_config.xml](../android/app/src/main/res/xml/network_security_config.xml) | §7.4, §10.6 |
|
||
| G12 | **The PIN encryption key travels over plain HTTP** before every encryption | [mpinCrypto.ts:53](../src/services/security/mpinCrypto.ts) | §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
|
||
|
||
```ts
|
||
// 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
|
||
|
||
```ts
|
||
// 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;
|
||
```
|
||
|
||
```ts
|
||
// 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.
|
||
|
||
```js
|
||
// 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.
|
||
|
||
```ts
|
||
// src/tenant/index.ts
|
||
import tenantConfig from '@tenant';
|
||
export const tenant = tenantConfig;
|
||
```
|
||
|
||
| File | Change |
|
||
|---|---|
|
||
| [env.ts](../src/config/env.ts) | `const API_ORIGIN = tenant.api[APP_ENV];` and `orgId: tenant.orgId` |
|
||
| [appConfig.ts](../src/config/appConfig.ts) | `appName: tenant.displayName`, `supportEmail: tenant.support.email` |
|
||
| [palette.ts](../src/theme/palette.ts) | `export const light: Palette = { ...sharedLight, ...tenant.brand.light }`, and the same for `dark` |
|
||
| [BrandLogo](../src/modules/auth/components/BrandLogo/index.tsx) | `const LOGO = tenant.logo;` |
|
||
| [featureConfig.ts:311](../src/features/featureConfig.ts) | `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:
|
||
|
||
```ts
|
||
// 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](../src/features/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](../src/modules/profile/screens/FeatureFlagsScreen.tsx) | 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
|
||
|
||
```mermaid
|
||
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](../src/features/featureService.ts) `resolveOwnFlagWithSource`:
|
||
|
||
```ts
|
||
/**
|
||
* 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](../src/types/app.ts)).
|
||
|
||
- [RootNavigator](../src/navigation/RootNavigator.tsx) 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](../src/features/adminFeatures.ts) |
|
||
|
||
---
|
||
|
||
## 9. How it works at runtime
|
||
|
||
### 9.1 Cold start
|
||
|
||
```mermaid
|
||
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](../src/features/featureRemoteConfig.ts):
|
||
|
||
```ts
|
||
/** 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
|
||
|
||
```mermaid
|
||
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`:
|
||
|
||
```tsx
|
||
// 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
|
||
|
||
```mermaid
|
||
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](../App.tsx)), closes G7:
|
||
|
||
```ts
|
||
// 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](../src/services/api/interceptors.ts)). 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](../src/hooks/useAppBootstrap.ts)).
|
||
2. `featureRemoteConfig.forgetRoleScope()` deletes it from the disk cache ([appDataPurge.ts:75](../src/services/storage/appDataPurge.ts)).
|
||
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
|
||
|
||
```mermaid
|
||
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](../src/navigation/LoanNavigator.tsx) | 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](../src/types/cbs.ts)):
|
||
|
||
```json
|
||
{
|
||
"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](../src/features/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](BANK_FLOW_VARIANTS.md#10-one-config-response) |
|
||
|
||
### 10.2 Data model
|
||
|
||
```sql
|
||
-- 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](../src/navigation/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](../src/services/api/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.
|
||
|
||
```mermaid
|
||
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](../src/services/security/mpinCrypto.ts)), 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`:
|
||
|
||
```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
|
||
|
||
```gradle
|
||
// 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:
|
||
|
||
```xml
|
||
<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
|
||
|
||
```js
|
||
// 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](BANK_FLOW_VARIANTS.md#4-rung-1--bank-settings)) | Next config refresh | One bank |
|
||
| Different security steps (TPIN, OTP, biometric, step-up by amount) | Server challenge policy ([flow variants §5](BANK_FLOW_VARIANTS.md#5-rung-2--server-driven-challenges-security-flows)) | Next flow started | One bank, amount or customer |
|
||
| Different steps in a journey | Journey variant ([flow variants §6](BANK_FLOW_VARIANTS.md#6-rung-3--journey-variants-non-security-flows)) | 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](../src/config/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](../src/features/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](BANK_FLOW_VARIANTS.md#14-migration-plan). 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](BANK_FLOW_VARIANTS.md#5-rung-2--server-driven-challenges-security-flows) |
|
||
|
||
---
|
||
|
||
## Appendix: file map
|
||
|
||
| File | Phase | Change |
|
||
|---|---|---|
|
||
| [src/features/featureRemoteConfig.ts](../src/features/featureRemoteConfig.ts) | 1 | `orgId` param, fetch timeout |
|
||
| [src/features/featureService.ts](../src/features/featureService.ts) | 1 | `localEnableAllowed`, off-only rule, `explain()` |
|
||
| [src/features/featureService.test.ts](../src/features/featureService.test.ts) | 1 | New cases (§14) |
|
||
| `src/features/useFeatureRefresh.ts` | 1 | New — foreground refresh |
|
||
| [src/hooks/useAppBootstrap.ts](../src/hooks/useAppBootstrap.ts) | 1 | `sessionFeaturesReady` around the sign-in refresh |
|
||
| [src/navigation/RootNavigator.tsx](../src/navigation/RootNavigator.tsx) | 1 | Hold the app stack until session flags land |
|
||
| [App.tsx](../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](../metro.config.js) | 2 | `@tenant` resolver |
|
||
| [tsconfig.json](../tsconfig.json), [jest.config.js](../jest.config.js), [package.json](../package.json) | 2 | Path mapping, mapper, per-bank scripts |
|
||
| [src/config/env.ts](../src/config/env.ts) | 2 | `orgId`, `API_ORIGIN` from tenant |
|
||
| [src/config/appConfig.ts](../src/config/appConfig.ts) | 2 | `appName`, `supportEmail` from tenant |
|
||
| [src/theme/palette.ts](../src/theme/palette.ts) | 2 | Brand roles from tenant |
|
||
| [src/modules/auth/components/BrandLogo/index.tsx](../src/modules/auth/components/BrandLogo/index.tsx) | 2 | Logo from tenant |
|
||
| [src/features/featureConfig.ts](../src/features/featureConfig.ts) | 2 | `ENVIRONMENT_FEATURE_OVERRIDES` from tenant; rule A cleanup |
|
||
| Copy naming "BankCBS": [LoginScreen.tsx:536](../src/modules/auth/screens/LoginScreen.tsx), [AwarenessDetailScreen.tsx:36,105](../src/modules/awareness/screens/AwarenessDetailScreen.tsx), [TransferToOwnAccountScreen.tsx:90](../src/modules/payments/screens/TransferToOwnAccountScreen.tsx), [SchemeDetailScreen.tsx:35](../src/modules/schemes/screens/SchemeDetailScreen.tsx), [SchemeEnrollmentScreen.tsx:188](../src/modules/schemes/screens/SchemeEnrollmentScreen.tsx), [PaymentMethodTile.tsx:16](../src/modules/loan/components/PaymentMethodTile.tsx), [osmLocator.ts:32](../src/modules/services/services/osmLocator.ts) | 2 | `tenant.shortName` / `tenant.copy` |
|
||
| [android/app/build.gradle](../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](../android/app/src/main/AndroidManifest.xml) | 3, 5 | Hot Updater channel placeholder |
|
||
| `ios/config/*.xcconfig`, [Info.plist](../ios/BankCBS/Info.plist), [Podfile](../ios/Podfile) | 3 | Per-bank configurations |
|
||
| `scripts/release.js`, `scripts/export-feature-catalog.js` | 3 | New |
|
||
| [src/services/api/cbsErrors.ts](../src/services/api/cbsErrors.ts) | 4 | `FEATURE_NOT_AVAILABLE` |
|
||
| [src/types/cbs.ts](../src/types/cbs.ts) | 4 | Document `roleOverrides` as session entitlements; login response `features` |
|