- 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>
56 KiB
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
- The problem
- How it works in 60 seconds
- The big picture
- Terms
- What exists today, and the gaps
- Target codebase layout
- The tenant pack (bank identity)
- The feature entitlement model
- How it works at runtime
- Backend design
- Build, distribution and OTA
- Which change goes where
- Security model
- Testing
- Migration plan
- Adding a new bank
- Open questions
- 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
- 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. - 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. - 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. - 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. - Delivery. One script builds every bank's store app. JS changes go out by OTA (Hot Updater), one channel per bank.
- 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: falseretires 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 atruefor something the bank has off. - Booleans only. Numeric entitlements (transfer limits, daily caps) belong in a separate typed
limitsresponse, 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/loginreturnsfeatures: { version, overrides, roleOverrides }in its response. The app applies it before settingisAuthenticated. One round trip, and the flags can never arrive late. - Interim (app-only): add a
sessionFeaturesReadyflag to theuislice. Set itfalseon sign-in, run the refresh, then set ittruein afinally.RootNavigatorrendersSplashScreenwhile it isfalse:
// 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:
featureRemoteConfig.clearRoleScope()drops the session layer from memory (useAppBootstrap.ts:84).featureRemoteConfig.forgetRoleScope()deletes it from the disk cache (appDataPurge.ts:75).- 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.versionand writesfeature_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)andHOT_UPDATER_CHANNEL = $(HOT_UPDATER_CHANNEL).- One build configuration (
Release-BankA-Prod) and one scheme per bank and environment. Map each configuration in thePodfile:'Release-BankA-Prod' => :release. - Xcode exports build settings as environment variables to the Bundle React Native code and images phase, so Metro sees
TENANTwith 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 | 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 |