Files
CBS-Mobile-App/src/features/featureRemoteConfig.ts
T

227 lines
7.5 KiB
TypeScript

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<string, boolean> | 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<CachedConfig | null> => {
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<string, boolean>, 'cached config'),
roleOverrides: narrow(parsed.roleOverrides as Record<string, boolean>, '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<FeatureConfigResult> => {
const response = await apiClient.get<FeatureConfigResult>(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<boolean> {
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<boolean> {
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: {} });
},
};