227 lines
7.5 KiB
TypeScript
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: {} });
|
|
},
|
|
};
|