import type { AxiosError, AxiosInstance, AxiosResponse } from 'axios'; import type { CbsEnvelope } from '../../types/cbs'; import { sessionManager } from '../security/sessionManager'; import { secureStorage } from '../storage/secureStorage'; import { apiLogger } from './apiLogger'; import { CbsApiError, isCbsErrorCode, type FieldErrors } from './cbsErrors'; import { rememberServerTime } from './serverClock'; const unauthorizedHandlers = new Set<() => void>(); export const registerUnauthorizedHandler = (handler: () => void) => { unauthorizedHandlers.add(handler); return () => { unauthorizedHandlers.delete(handler); }; }; const looksLikeEnvelope = (value: unknown): value is CbsEnvelope => typeof value === 'object' && value !== null && 'success' in value; /** * `VALIDATION_ERROR` is the one failure that also fills `data`, with a * field-to-message map. Anything else in there on a failure is not a field map. */ const readFieldErrors = (envelope: CbsEnvelope): FieldErrors | undefined => { if (envelope.errorCode !== 'VALIDATION_ERROR') { return undefined; } const { data } = envelope; if (typeof data !== 'object' || data === null) { return undefined; } const entries = Object.entries(data as Record).filter( (entry): entry is [string, string] => typeof entry[1] === 'string', ); return entries.length > 0 ? Object.fromEntries(entries) : undefined; }; /** The absolute URL a failed request was aimed at, for a dev-only diagnostic. */ const describeTarget = (error: AxiosError) => { const base = error.config?.baseURL ?? ''; const path = error.config?.url ?? ''; return `${base}${path}` || 'the server'; }; const envelopeToError = (status: number, envelope: CbsEnvelope) => new CbsApiError({ status, code: isCbsErrorCode(envelope.errorCode) ? envelope.errorCode : 'UNKNOWN_ERROR', message: envelope.message || 'Unexpected service error', fieldErrors: readFieldErrors(envelope), }); /** * `SESSION_INVALID` means the token we hold is dead and every screen must drop * it. `INVALID_CREDENTIALS` shares the 401 but means a wrong MPIN on the * sign-in form — bouncing the user out of a screen they are not in yet would * be nonsense, so only the former trips the global handler. */ export const handleSessionLoss = async (error: CbsApiError) => { if (error.code !== 'SESSION_INVALID') { return; } await secureStorage.clearSession(); sessionManager.clear(); unauthorizedHandlers.forEach((handler) => handler()); }; export const attachInterceptors = (client: AxiosInstance) => { client.interceptors.request.use(async (config) => { const token = await secureStorage.getAccessToken(); if (token) { const nextHeaders = (config.headers ?? {}) as Record; nextHeaders.Authorization = `Bearer ${token}`; config.headers = nextHeaders as never; } // Logged here, at the end of the interceptor, so the curl shows the // headers the request will actually be sent with — the token itself as a // placeholder. apiLogger.request(config); return config; }); client.interceptors.response.use( async (response: AxiosResponse) => { apiLogger.response(response); if (!looksLikeEnvelope(response.data)) { return response; } const envelope = response.data as CbsEnvelope; // Branch on `success`, not on the HTTP status: a failure can arrive with // a 2xx, and treating it as a payload would hand callers `undefined`. if (!envelope.success) { const error = envelopeToError(response.status, envelope); apiLogger.failure(response, `envelope said success: false -> ${error.code}`); await handleSessionLoss(error); throw error; } // Any authenticated call refreshes the server's idle timer, so mirror // that locally — otherwise the app would sign the user out while the // session is still very much alive. if (response.config.headers?.Authorization) { sessionManager.touch(); } // Unwrap so callers read the payload directly rather than the envelope. // The server's `timestamp` is kept aside for anything counting down to // a server expiry — see `serverClock`. const unwrapped = { ...response, data: envelope.data }; rememberServerTime(unwrapped, envelope.timestamp); return unwrapped; }, async (error: AxiosError) => { if (error instanceof CbsApiError) { return Promise.reject(error); } if (error.response) { apiLogger.failure(error.response, `HTTP error ${error.code ?? ''}`.trim()); } else { apiLogger.networkFailure(error); } const status = Number(error.response?.status ?? 0); const body = error.response?.data; if (looksLikeEnvelope(body)) { const cbsError = envelopeToError(status, body); await handleSessionLoss(cbsError); return Promise.reject(cbsError); } if (error.code === 'ECONNABORTED' || error.code === 'ETIMEDOUT') { return Promise.reject( new CbsApiError({ status: 0, code: 'REQUEST_TIMEOUT', message: 'The request timed out', }), ); } // No response at all: nothing answered on the other end. The usual causes // are the backend not running, it being bound to 127.0.0.1 instead of // 0.0.0.0, the handset being on a different network from the host, a // firewall on the host port, or cleartext HTTP blocked in a release build. // // Naming the URL that failed matters more than it looks: without it this // is indistinguishable from a server-side fault, and the address the // phone needs is often not the one that works in the laptop's browser. if (!error.response) { return Promise.reject( new CbsApiError({ status: 0, code: 'NETWORK_ERROR', message: __DEV__ ? `Could not reach ${describeTarget(error)}. Check the backend is running and that this device can reach that address.` : error.message || 'Could not reach the server', }), ); } return Promise.reject( new CbsApiError({ status, code: 'UNKNOWN_ERROR', message: error.message || 'Unexpected service error', }), ); }, ); };