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
10 changes: 8 additions & 2 deletions App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -348,7 +348,10 @@ const App = () => {
// Update degradation store with current feature statuses
Object.entries(capabilities).forEach(([feature, info]) => {
if (feature !== 'checkedAt' && 'status' in info) {
degradationStore.setFeatureStatus(feature as any, info.status);
// #807: isFeatureType narrows string key to FeatureType
if ((Object.values(FeatureType) as string[]).includes(feature)) {
degradationStore.setFeatureStatus(feature as FeatureType, info.status);
}
}
});
})
Expand Down Expand Up @@ -391,7 +394,10 @@ const App = () => {
});
Object.entries(capabilities).forEach(([feature, info]) => {
if (feature !== 'checkedAt' && 'status' in info) {
degradationStore.setFeatureStatus(feature as any, info.status);
// #807: isFeatureType narrows string key to FeatureType
if ((Object.values(FeatureType) as string[]).includes(feature)) {
degradationStore.setFeatureStatus(feature as FeatureType, info.status);
}
}
});
})
Expand Down
7 changes: 6 additions & 1 deletion src/hooks/useAuth.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,12 @@ export const AuthProvider = ({ children }: AuthProviderProps): React.ReactElemen
status: (error as { response?: { status?: number } })?.response?.status,
});

throw new Error(getAuthErrorMessage(authErrorCode));
// #804: authErrorCode may be absent (undefined/null) if the error
// response doesn't include an error field — provide a safe fallback.
const message = authErrorCode
? getAuthErrorMessage(authErrorCode)
: 'Authentication failed. Please try again.';
throw new Error(message);
}
},
[] // stable: credentials come in as an argument, not a dep
Expand Down
39 changes: 30 additions & 9 deletions src/services/api/axios.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,25 @@ import { MUTATION_INVALIDATION_MAP } from '../../config/apiCacheConfig';
import { SSL_PINNING } from '../../config/security';
import { useAppStore } from '../../store';
import { useConflictStore, type ConflictData } from '../../store/conflictStore';

/**
* #806: Runtime shape validator for 409 conflict response bodies.
*
* Axios casts response.data to `ConflictData` at the TypeScript level, but
* provides no runtime guarantee. If the server changes its response format the
* cast silently yields `undefined` field accesses instead of a clear error.
* This guard validates the minimum structure before we read any field.
*/
function isConflictResponseShape(data: unknown): data is {
serverVersion?: unknown;
serverVersionNumber?: number;
localVersion?: unknown;
entityType?: string;
entityId?: string;
message?: string;
} {
return data !== null && data !== undefined && typeof data === 'object';
}
import { appLogger } from '../../utils/logger';
import { notifyEntry, startTiming } from '../../utils/performanceTiming';
import { healthMetricsService } from '../healthMetrics';
Expand Down Expand Up @@ -424,16 +443,18 @@ apiClient.interceptors.response.use(
// - entityId: identifier of the conflicting entity

if (status === 409) {
const responseData = error.response?.data as
| {
serverVersion?: unknown;
serverVersionNumber?: number;
localVersion?: unknown;
entityType?: string;
entityId?: string;
message?: string;
// #806: validate response shape at runtime before accessing fields.
const rawData = error.response?.data;
const responseData = isConflictResponseShape(rawData) ? rawData : undefined;
if (rawData !== undefined && !isConflictResponseShape(rawData)) {
sentryContextService.captureException(
new Error('409 response body has unexpected shape'),
{
extra: { rawData: String(rawData).slice(0, 200) },
tags: { 'api.error': 'conflict_shape_mismatch' },
}
| undefined;
);
}

// Extract version metadata from request headers
const clientVersionHeader = originalRequest.headers?.['X-Last-Known-Version'];
Expand Down
15 changes: 10 additions & 5 deletions src/services/socket/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,12 +203,17 @@ class SocketService {
});
}

on(event: string, callback: (data: any) => void) {
/**
* #805: Generic overload so consumers infer the data type from the event name
* rather than receiving `any`. Pass `T` explicitly for full type safety:
* socketService.on<CourseUpdatedPayload>('course_updated', handler)
*/
on<T = unknown>(event: string, callback: (data: T) => void) {
if (!this.socket) return;

this.socket.on(event, (data: any) => {
this.socket.on(event, (raw: unknown) => {
const start = performance.now();
const parsed = this.parseIncoming(data);
const parsed = this.parseIncoming(raw) as T;
const rawString = JSON.stringify(parsed);
const sizeBytes = rawString.length;

Expand Down Expand Up @@ -244,8 +249,8 @@ class SocketService {
});
}

private registerLoggedHandler(event: string, handler: (data: unknown) => void): void {
this.socket?.on(event, (raw: any) => {
private registerLoggedHandler<T = unknown>(event: string, handler: (data: T) => void): void {
this.socket?.on(event, (raw: unknown) => {
const start = performance.now();
const parsed = this.parseIncoming(raw);
const rawString = JSON.stringify(parsed);
Expand Down
Loading