From fe13d8f1edc6e4c85ae3e5f43647f03486580e05 Mon Sep 17 00:00:00 2001 From: soundsng Date: Tue, 28 Jul 2026 12:23:22 +0100 Subject: [PATCH] fix: make auth and storage failures explicit and recoverable Adds four small, self-contained modules: - timeoutRetryPolicy: decides which timed-out requests belong in the offline queue so writes are replayed on reconnect instead of silently lost (#788) - secureStorageHealth: round-trips a probe through the secure store and fails fatally, so the app cannot boot into an insecure fallback (#789) - authInitError: structured AuthInitializationError naming the missing dependency instead of a bare "not initialized" error (#790) - authErrorCategory: categorises auth failures and maps each to its own recovery action (#791) --- src/services/secureStorageHealth.ts | 50 +++++++++++++++++++++++++++++ src/types/authInitError.ts | 41 +++++++++++++++++++++++ src/utils/authErrorCategory.ts | 50 +++++++++++++++++++++++++++++ src/utils/timeoutRetryPolicy.ts | 35 ++++++++++++++++++++ 4 files changed, 176 insertions(+) create mode 100644 src/services/secureStorageHealth.ts create mode 100644 src/types/authInitError.ts create mode 100644 src/utils/authErrorCategory.ts create mode 100644 src/utils/timeoutRetryPolicy.ts diff --git a/src/services/secureStorageHealth.ts b/src/services/secureStorageHealth.ts new file mode 100644 index 0000000..040fa8a --- /dev/null +++ b/src/services/secureStorageHealth.ts @@ -0,0 +1,50 @@ +/** + * Secure storage initialisation verification. (#789) + * + * Initialisation caught its own failures and still reported success, so the + * app could boot with an unusable Keychain and fall back to storing tokens in + * plaintext. A failed probe is fatal: the caller shows a recovery screen. + */ + +export type StorageFailureStage = 'write' | 'read' | 'verify'; + +/** Fatal: the app must not continue with an untrustworthy secure store. */ +export class SecureStorageUnavailableError extends Error { + readonly code = 'SECURE_STORAGE_UNAVAILABLE'; + readonly stage: StorageFailureStage; + readonly recoveryHint = 'Clear app data and restart. If it persists, reinstall.'; + constructor(stage: StorageFailureStage, cause?: unknown) { + super(`Secure storage failed during ${stage} and cannot be trusted.`); + this.name = 'SecureStorageUnavailableError'; + this.stage = stage; + if (cause !== undefined) this.cause = cause; + } +} +const PROBE_KEY = '__secure_storage_probe__'; + +export interface StorageProbe { + setItem: (key: string, value: string) => Promise; + getItem: (key: string) => Promise; + removeItem: (key: string) => Promise; +} + +/** Round-trips a probe value, throwing if it cannot be written and read back. */ +export async function assertSecureStorageWorks(store: StorageProbe): Promise { + const expected = `probe-${Date.now()}`; + let actual: string | null; + + try { + await store.setItem(PROBE_KEY, expected); + } catch (cause) { + throw new SecureStorageUnavailableError('write', cause); + } + + try { + actual = await store.getItem(PROBE_KEY); + } catch (cause) { + throw new SecureStorageUnavailableError('read', cause); + } + + await store.removeItem(PROBE_KEY).catch(() => undefined); + if (actual !== expected) throw new SecureStorageUnavailableError('verify'); +} diff --git a/src/types/authInitError.ts b/src/types/authInitError.ts new file mode 100644 index 0000000..f2296ee --- /dev/null +++ b/src/types/authInitError.ts @@ -0,0 +1,41 @@ +/** + * Structured auth initialisation error. (#790) + * + * Auth operations threw a bare "not initialized" Error, so a caller could not + * tell which dependency was missing — secure storage, the API client, the + * signing secret — nor react to it programmatically. This names the + * dependency and is `instanceof`-checkable. + */ + +export type AuthDependency = 'secureStore' | 'apiClient' | 'jwtSecret' | 'sessionCache'; + +const DEPENDENCY_LABELS: Record = { + secureStore: 'secure storage (Keychain/Keystore)', + apiClient: 'the API client', + jwtSecret: 'the JWT signing secret', + sessionCache: 'the session cache', +}; + +/** Thrown when an auth operation runs before its dependencies are ready. */ +export class AuthInitializationError extends Error { + readonly code = 'AUTH_NOT_INITIALIZED'; + readonly missingDependency: AuthDependency; + + constructor(missingDependency: AuthDependency) { + super( + `Authentication is unavailable: ${DEPENDENCY_LABELS[missingDependency]} is not initialized.` + ); + this.name = 'AuthInitializationError'; + this.missingDependency = missingDependency; + } +} + +/** Narrowing guard for callers that need the missing dependency name. */ +export function isAuthInitializationError(error: unknown): error is AuthInitializationError { + return error instanceof AuthInitializationError; +} + +/** Guard clause helper: throws AuthInitializationError when `ready` is false. */ +export function assertAuthDependency(ready: boolean, dependency: AuthDependency): void { + if (!ready) throw new AuthInitializationError(dependency); +} diff --git a/src/utils/authErrorCategory.ts b/src/utils/authErrorCategory.ts new file mode 100644 index 0000000..47a8564 --- /dev/null +++ b/src/utils/authErrorCategory.ts @@ -0,0 +1,50 @@ +/** + * Auth error categorisation. (#791) + * + * Every auth failure went through one message lookup, so a dropped connection + * and a locked account produced the same text and the same recovery action. + * Callers use the category to pick the right affordance. + */ +export enum AuthErrorCategory { + NETWORK = 'NETWORK', + INVALID_CREDENTIALS = 'INVALID_CREDENTIALS', + ACCOUNT_LOCKED = 'ACCOUNT_LOCKED', + SESSION_EXPIRED = 'SESSION_EXPIRED', + UNKNOWN = 'UNKNOWN', +} + +export type AuthRecoveryAction = 'retry' | 'reenterCredentials' | 'contactSupport' | 'signIn'; +const RECOVERY_ACTIONS: Record = { + [AuthErrorCategory.NETWORK]: 'retry', + [AuthErrorCategory.INVALID_CREDENTIALS]: 'reenterCredentials', + [AuthErrorCategory.ACCOUNT_LOCKED]: 'contactSupport', + [AuthErrorCategory.SESSION_EXPIRED]: 'signIn', + [AuthErrorCategory.UNKNOWN]: 'retry', +}; +const NETWORK_CODES = new Set(['ECONNABORTED', 'ERR_NETWORK', 'ETIMEDOUT']); +export interface CategorisableAuthError { + status?: number; + code?: string; +} +/** Maps a transport or auth failure onto a category. */ +export function categoriseAuthError(error: CategorisableAuthError): AuthErrorCategory { + if ((error.code && NETWORK_CODES.has(error.code)) || error.status === 0) { + return AuthErrorCategory.NETWORK; + } + switch (error.status) { + case 401: + return AuthErrorCategory.INVALID_CREDENTIALS; + case 403: + return AuthErrorCategory.ACCOUNT_LOCKED; + case 419: + case 440: + return AuthErrorCategory.SESSION_EXPIRED; + default: + return AuthErrorCategory.UNKNOWN; + } +} + +/** The recovery affordance the UI should offer for a category. */ +export function getRecoveryAction(category: AuthErrorCategory): AuthRecoveryAction { + return RECOVERY_ACTIONS[category]; +} diff --git a/src/utils/timeoutRetryPolicy.ts b/src/utils/timeoutRetryPolicy.ts new file mode 100644 index 0000000..cfbdbc2 --- /dev/null +++ b/src/utils/timeoutRetryPolicy.ts @@ -0,0 +1,35 @@ +/** + * Retry policy for timed-out requests. (#788) + * + * ECONNABORTED timeouts were logged and then dropped, so quiz submissions and + * lesson completions vanished on slow networks. This decides which timed-out + * requests belong in the offline queue: writes are replayed on reconnect, + * reads are not — they are cheap to re-fetch and replaying them can resurface + * stale data. + */ + +const RETRYABLE_METHODS = new Set(['post', 'put', 'patch', 'delete']); + +export interface TimedOutRequest { + method?: string; + code?: string; +} + +/** True when the failure is an axios client-side timeout. */ +export function isTimeoutError(error: TimedOutRequest): boolean { + return error.code === 'ECONNABORTED'; +} + +/** + * True when a timed-out request should be queued for replay on reconnect. + * Only non-idempotent writes qualify; GET/HEAD are re-fetched instead. + */ +export function shouldQueueForRetry(error: TimedOutRequest): boolean { + if (!isTimeoutError(error)) return false; + const method = error.method?.toLowerCase(); + return method !== undefined && RETRYABLE_METHODS.has(method); +} + +/** Non-blocking confirmation shown once a request has been queued. */ +export const QUEUED_ACTION_MESSAGE = + "Saved — we'll finish this when you're back online.";