Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions src/services/secureStorageHealth.ts
Original file line number Diff line number Diff line change
@@ -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<void>;
getItem: (key: string) => Promise<string | null>;
removeItem: (key: string) => Promise<void>;
}

/** Round-trips a probe value, throwing if it cannot be written and read back. */
export async function assertSecureStorageWorks(store: StorageProbe): Promise<void> {
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');
}
41 changes: 41 additions & 0 deletions src/types/authInitError.ts
Original file line number Diff line number Diff line change
@@ -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<AuthDependency, string> = {
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);
}
50 changes: 50 additions & 0 deletions src/utils/authErrorCategory.ts
Original file line number Diff line number Diff line change
@@ -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, AuthRecoveryAction> = {
[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];
}
35 changes: 35 additions & 0 deletions src/utils/timeoutRetryPolicy.ts
Original file line number Diff line number Diff line change
@@ -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.";
Loading