All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Add runtime type validation after all JSON.parse calls in the web app to prevent runtime crashes from corrupted or tampered storage data. Creates a shared safeJsonParse utility with type guard functions for each data shape (Message[], ChatOverlayState, LayoutConfigRecord). All four affected callsites now validate parsed data and fall back to safe defaults on mismatch. Files changed: - apps/web/src/lib/utils/safe-json.ts (new utility) - apps/web/src/lib/utils/safe-json.test.ts (25 tests) - apps/web/src/hooks/useChat.ts (deserializeMessages) - apps/web/src/hooks/useChat.test.ts (3 new corruption tests) - apps/web/src/hooks/useChatOverlay.ts (loadState) - apps/web/src/hooks/useChatOverlay.test.ts (3 new corruption tests) - apps/web/src/components/chat/ConversationSidebar.tsx (ideaToConversation) - apps/web/src/lib/hooks/useLayout.ts (layout loading) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
126 lines
3.6 KiB
TypeScript
126 lines
3.6 KiB
TypeScript
/**
|
|
* @file safe-json.ts
|
|
* @description Safe JSON parsing utilities with runtime type validation.
|
|
* Prevents runtime crashes from corrupted or tampered localStorage/API data.
|
|
*/
|
|
|
|
/**
|
|
* Safely parse a JSON string with runtime type validation.
|
|
* Returns the fallback value if parsing fails or the parsed data
|
|
* doesn't match the expected shape.
|
|
*
|
|
* @param json - The JSON string to parse
|
|
* @param validator - A type guard function that validates the parsed data
|
|
* @param fallback - The default value to return on failure
|
|
* @returns The validated parsed data or the fallback value
|
|
*/
|
|
export function safeJsonParse<T>(
|
|
json: string,
|
|
validator: (data: unknown) => data is T,
|
|
fallback: T
|
|
): T {
|
|
try {
|
|
const parsed: unknown = JSON.parse(json);
|
|
if (validator(parsed)) {
|
|
return parsed;
|
|
}
|
|
console.warn("safeJsonParse: parsed data failed validation, returning fallback");
|
|
return fallback;
|
|
} catch {
|
|
console.warn("safeJsonParse: failed to parse JSON, returning fallback");
|
|
return fallback;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Type guard: validates that a value is a non-null object
|
|
*/
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
}
|
|
|
|
/**
|
|
* Type guard: validates a chat Message shape
|
|
* Checks for required fields: id (string), role (valid enum), content (string), createdAt (string)
|
|
*/
|
|
export function isMessage(value: unknown): boolean {
|
|
if (!isRecord(value)) return false;
|
|
const validRoles = ["user", "assistant", "system"];
|
|
return (
|
|
typeof value.id === "string" &&
|
|
typeof value.role === "string" &&
|
|
validRoles.includes(value.role) &&
|
|
typeof value.content === "string" &&
|
|
typeof value.createdAt === "string"
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Type guard: validates an array of chat Messages
|
|
*/
|
|
export function isMessageArray(value: unknown): value is {
|
|
id: string;
|
|
role: "user" | "assistant" | "system";
|
|
content: string;
|
|
createdAt: string;
|
|
thinking?: string;
|
|
model?: string;
|
|
provider?: string;
|
|
promptTokens?: number;
|
|
completionTokens?: number;
|
|
totalTokens?: number;
|
|
}[] {
|
|
return Array.isArray(value) && value.every(isMessage);
|
|
}
|
|
|
|
/**
|
|
* Type guard: validates ChatOverlayState shape
|
|
* Expects { isOpen: boolean, isMinimized: boolean }
|
|
*/
|
|
export function isChatOverlayState(
|
|
value: unknown
|
|
): value is { isOpen: boolean; isMinimized: boolean } {
|
|
if (!isRecord(value)) return false;
|
|
return typeof value.isOpen === "boolean" && typeof value.isMinimized === "boolean";
|
|
}
|
|
|
|
/**
|
|
* Type guard: validates a WidgetPlacement shape
|
|
* Checks for required fields: i (string), x/y/w/h (numbers)
|
|
*/
|
|
function isWidgetPlacement(value: unknown): boolean {
|
|
if (!isRecord(value)) return false;
|
|
return (
|
|
typeof value.i === "string" &&
|
|
typeof value.x === "number" &&
|
|
typeof value.y === "number" &&
|
|
typeof value.w === "number" &&
|
|
typeof value.h === "number"
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Type guard: validates a LayoutConfig shape
|
|
* Expects { id: string, name: string, layout: WidgetPlacement[] }
|
|
*/
|
|
function isLayoutConfig(value: unknown): boolean {
|
|
if (!isRecord(value)) return false;
|
|
return (
|
|
typeof value.id === "string" &&
|
|
typeof value.name === "string" &&
|
|
Array.isArray(value.layout) &&
|
|
(value.layout as unknown[]).every(isWidgetPlacement)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Type guard: validates a Record<string, LayoutConfig> shape.
|
|
* Uses a branded type approach to ensure compatibility with LayoutConfig consumers.
|
|
*/
|
|
export function isLayoutConfigRecord(
|
|
value: unknown
|
|
): value is Record<string, { id: string; name: string; layout: unknown[] }> {
|
|
if (!isRecord(value)) return false;
|
|
return Object.values(value).every(isLayoutConfig);
|
|
}
|