import AsyncStorage from '@react-native-async-storage/async-storage'; import { toFeatureKey } from '../constants/featureKeys'; import { apiClient } from '../services/api/apiClient'; import { endpoints } from '../services/api/endpoints'; import { featureService } from './featureService'; import { FeatureSource, type FeatureOverrides } from './types'; import type { FeatureConfigResult } from '../types/cbs'; const CACHE_KEY = 'bankcbs.featureConfig.lastKnown'; /** * Server-driven feature switches — the `remote` and `tenant` layers. * * Two things here are load-bearing rather than incidental: * * **The last successful payload is cached and re-applied on launch.** Without it * a handset that starts offline would fall back to the flags compiled into the * bundle — which is exactly how a feature the bank killed during an incident * comes *back on* for anyone who restarts the app on a bad connection. Caching * the instruction is what makes the kill switch survive being offline. * * **Unknown keys are dropped, not trusted.** The server may legitimately * publish keys for a newer app release; this build must ignore those rather than * store strings that match nothing. Combined with fail-closed resolution, a * malformed payload can only ever hide features, never reveal them. */ interface CachedConfig { version: number; overrides: FeatureOverrides; roleOverrides: FeatureOverrides; fetchedAt: number; } /** Narrows a server payload to declared keys, discarding anything else. */ const narrow = (raw: Record | undefined, label: string): FeatureOverrides => { if (!raw || typeof raw !== 'object') { return {}; } const result: FeatureOverrides = {}; const unknown: string[] = []; for (const [candidate, value] of Object.entries(raw)) { const key = toFeatureKey(candidate); if (key && typeof value === 'boolean') { result[key] = value; } else { unknown.push(candidate); } } if (unknown.length > 0 && __DEV__) { console.warn( `[features] Ignored ${unknown.length} unrecognised key(s) in ${label}: ${unknown.join(', ')}`, ); } return result; }; const readCache = async (): Promise => { try { const stored = await AsyncStorage.getItem(CACHE_KEY); if (!stored) { return null; } const parsed = JSON.parse(stored) as CachedConfig; return { version: typeof parsed.version === 'number' ? parsed.version : 0, // Re-narrow on read: the cache may have been written by an older build // whose key set has since changed. overrides: narrow(parsed.overrides as Record, 'cached config'), roleOverrides: narrow(parsed.roleOverrides as Record, 'cached role config'), fetchedAt: typeof parsed.fetchedAt === 'number' ? parsed.fetchedAt : 0, }; } catch { return null; } }; const writeCache = async (config: CachedConfig) => { try { await AsyncStorage.setItem(CACHE_KEY, JSON.stringify(config)); } catch (error) { if (__DEV__) { console.warn('[features] Could not cache feature config.', error); } } }; /** * Fixture used while `useMockServer` is on, since `/features/config` has no * backend behind it yet. * * Deliberately empty. A fixture that switched features off would make mock mode * differ from a fresh install for reasons nobody could find — to exercise a kill * switch locally, use the dev toggle screen, or add an entry here temporarily: * * overrides: { 'loans.apply': false } */ const MOCK_CONFIG: FeatureConfigResult = { version: 0, overrides: {}, }; const fetchConfig = async (): Promise => { const response = await apiClient.get(endpoints.features.config); return response.data; }; export const featureRemoteConfig = { /** * Applies the cached configuration, if any. Call before the first fetch so a * cold start on a dead connection still honours the bank's last instruction * instead of reverting to bundle defaults. */ async applyCached(): Promise { const cached = await readCache(); if (!cached) { return false; } featureService.setSource(FeatureSource.Remote, cached.overrides); featureService.setSource(FeatureSource.Tenant, cached.roleOverrides); return true; }, /** * Fetches the live configuration and applies it to the remote and tenant * layers. * * Returns `false` when nothing could be applied, having left the existing * layers untouched — a failed refresh must never widen access, so the last * known configuration stays in force rather than falling back to defaults. * * Safe to call unauthenticated: the response then carries no `roleOverrides` * and the tenant layer is left as it is. */ async refresh({ useMock }: { useMock: boolean }): Promise { try { const result = useMock ? MOCK_CONFIG : await fetchConfig(); const overrides = narrow(result.overrides, 'feature config'); const hasRoleScope = result.roleOverrides !== undefined; const roleOverrides = narrow(result.roleOverrides, 'role feature config'); featureService.setSource(FeatureSource.Remote, overrides); // Only touch the tenant layer when the response actually carried a role // scope. An unauthenticated fetch omits it, and clearing the layer then // would drop role rules that a previous authenticated fetch had applied. if (hasRoleScope) { featureService.setSource(FeatureSource.Tenant, roleOverrides); } await writeCache({ version: result.version ?? 0, overrides, roleOverrides: hasRoleScope ? roleOverrides : featureService.getSource(FeatureSource.Tenant), fetchedAt: Date.now(), }); if (__DEV__) { const killed = Object.entries(overrides).filter(([, on]) => !on).length; console.log( `[features] Applied remote config v${result.version ?? 0} — ` + `${Object.keys(overrides).length} override(s), ${killed} disabled.`, ); } return true; } catch (error) { if (__DEV__) { console.warn('[features] Feature config refresh failed; keeping current config.', error); } return false; } }, /** * Clears the role-scoped layer on sign-out. The org-wide layer stays: it * applies to the sign-in screen too, and it is not session data. */ clearRoleScope() { featureService.setSource(FeatureSource.Tenant, {}); }, /** * Forgets the signed-out customer's entitlements for good, keeping the bank's * org-wide instruction. * * `clearRoleScope` only drops the in-memory layer. The cache on disk still * held `roleOverrides`, and `applyCached` re-applies both layers on the next * cold start — so one customer's role scope came back and sat over whoever * launched the app next. That is the leak this closes. * * The org-wide half is rewritten rather than deleted, deliberately. It is the * bank's kill switch, and it is cached precisely so a handset that starts * offline still honours a feature the bank killed during an incident instead * of reverting to whatever the bundle was built with. Dropping the file would * turn a sign-out into a way to switch those features back on. */ async forgetRoleScope() { featureService.setSource(FeatureSource.Tenant, {}); const cached = await readCache(); if (!cached || Object.keys(cached.roleOverrides).length === 0) { return; } await writeCache({ ...cached, roleOverrides: {} }); }, };