chore: upgrade Node.js runtime to v24 across codebase #419

Merged
jason.woltje merged 438 commits from fix/auth-frontend-remediation into main 2026-02-17 01:04:47 +00:00
8 changed files with 516 additions and 22 deletions
Showing only changes of commit 14b547d468 - Show all commits

View File

@@ -1,9 +1,9 @@
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
"use client";
import { useState, useEffect, forwardRef, useImperativeHandle, useCallback } from "react";
import { getConversations, type Idea } from "@/lib/api/ideas";
import { useAuth } from "@/lib/auth/auth-context";
import { safeJsonParse, isMessageArray } from "@/lib/utils/safe-json";
interface ConversationSummary {
id: string;
@@ -41,15 +41,9 @@ export const ConversationSidebar = forwardRef<ConversationSidebarRef, Conversati
* Convert Idea to ConversationSummary
*/
const ideaToConversation = useCallback((idea: Idea): ConversationSummary => {
// Count messages from the stored JSON content
let messageCount = 0;
try {
const messages = JSON.parse(idea.content);
messageCount = Array.isArray(messages) ? messages.length : 0;
} catch {
// If parsing fails, assume 0 messages
messageCount = 0;
}
// Count messages from the stored JSON content with runtime validation
const messages = safeJsonParse(idea.content, isMessageArray, []);
const messageCount = messages.length;
return {
id: idea.id,

View File

@@ -320,6 +320,59 @@ describe("useChat", () => {
expect(result.current.conversationId).toBe("conv-123");
expect(result.current.conversationTitle).toBe("My Conversation");
});
it("should fall back to welcome message when stored JSON is corrupted", async () => {
vi.spyOn(console, "warn").mockImplementation(() => undefined);
mockGetIdea.mockResolvedValueOnce(
createMockIdea("conv-bad", "Corrupted", "not valid json {{{")
);
const { result } = renderHook(() => useChat());
await act(async () => {
await result.current.loadConversation("conv-bad");
});
// Should fall back to welcome message
expect(result.current.messages).toHaveLength(1);
expect(result.current.messages[0]?.id).toBe("welcome");
});
it("should fall back to welcome message when stored data has wrong shape", async () => {
vi.spyOn(console, "warn").mockImplementation(() => undefined);
// Valid JSON but wrong shape (object instead of array, missing required fields)
mockGetIdea.mockResolvedValueOnce(
createMockIdea("conv-bad", "Wrong Shape", JSON.stringify({ not: "an array" }))
);
const { result } = renderHook(() => useChat());
await act(async () => {
await result.current.loadConversation("conv-bad");
});
expect(result.current.messages).toHaveLength(1);
expect(result.current.messages[0]?.id).toBe("welcome");
});
it("should fall back to welcome message when messages have invalid roles", async () => {
vi.spyOn(console, "warn").mockImplementation(() => undefined);
const badMessages = [
{ id: "msg-1", role: "hacker", content: "Bad", createdAt: "2026-01-01" },
];
mockGetIdea.mockResolvedValueOnce(
createMockIdea("conv-bad", "Bad Roles", JSON.stringify(badMessages))
);
const { result } = renderHook(() => useChat());
await act(async () => {
await result.current.loadConversation("conv-bad");
});
expect(result.current.messages).toHaveLength(1);
expect(result.current.messages[0]?.id).toBe("welcome");
});
});
describe("startNewConversation", () => {

View File

@@ -6,6 +6,7 @@
import { useState, useCallback, useRef } from "react";
import { sendChatMessage, type ChatMessage as ApiChatMessage } from "@/lib/api/chat";
import { createConversation, updateConversation, getIdea, type Idea } from "@/lib/api/ideas";
import { safeJsonParse, isMessageArray } from "@/lib/utils/safe-json";
export interface Message {
id: string;
@@ -111,15 +112,10 @@ export function useChat(options: UseChatOptions = {}): UseChatReturn {
}, []);
/**
* Deserialize messages from JSON
* Deserialize messages from JSON with runtime type validation
*/
const deserializeMessages = useCallback((json: string): Message[] => {
try {
const parsed = JSON.parse(json) as Message[];
return Array.isArray(parsed) ? parsed : [WELCOME_MESSAGE];
} catch {
return [WELCOME_MESSAGE];
}
return safeJsonParse(json, isMessageArray, [WELCOME_MESSAGE]);
}, []);
/**

View File

@@ -64,6 +64,7 @@ describe("useChatOverlay", () => {
});
it("should handle invalid localStorage data gracefully", () => {
vi.spyOn(console, "warn").mockImplementation(() => undefined);
localStorageMock.setItem("chatOverlayState", "invalid json");
const { result } = renderHook(() => useChatOverlay());
@@ -71,6 +72,37 @@ describe("useChatOverlay", () => {
expect(result.current.isOpen).toBe(false);
expect(result.current.isMinimized).toBe(false);
});
it("should fall back to defaults when localStorage has wrong shape", () => {
vi.spyOn(console, "warn").mockImplementation(() => undefined);
// Valid JSON but wrong shape
localStorageMock.setItem("chatOverlayState", JSON.stringify({ isOpen: "yes", wrong: true }));
const { result } = renderHook(() => useChatOverlay());
expect(result.current.isOpen).toBe(false);
expect(result.current.isMinimized).toBe(false);
});
it("should fall back to defaults when localStorage has null value parsed", () => {
vi.spyOn(console, "warn").mockImplementation(() => undefined);
localStorageMock.setItem("chatOverlayState", "null");
const { result } = renderHook(() => useChatOverlay());
expect(result.current.isOpen).toBe(false);
expect(result.current.isMinimized).toBe(false);
});
it("should fall back to defaults when localStorage has array instead of object", () => {
vi.spyOn(console, "warn").mockImplementation(() => undefined);
localStorageMock.setItem("chatOverlayState", JSON.stringify([true, false]));
const { result } = renderHook(() => useChatOverlay());
expect(result.current.isOpen).toBe(false);
expect(result.current.isMinimized).toBe(false);
});
});
describe("open", () => {

View File

@@ -4,6 +4,7 @@
*/
import { useState, useEffect, useCallback } from "react";
import { safeJsonParse, isChatOverlayState } from "@/lib/utils/safe-json";
interface ChatOverlayState {
isOpen: boolean;
@@ -27,7 +28,7 @@ const DEFAULT_STATE: ChatOverlayState = {
};
/**
* Load state from localStorage
* Load state from localStorage with runtime type validation
*/
function loadState(): ChatOverlayState {
if (typeof window === "undefined") {
@@ -37,7 +38,7 @@ function loadState(): ChatOverlayState {
try {
const stored = window.localStorage.getItem(STORAGE_KEY);
if (stored) {
return JSON.parse(stored) as ChatOverlayState;
return safeJsonParse(stored, isChatOverlayState, DEFAULT_STATE);
}
} catch (error) {
console.warn("Failed to load chat overlay state from localStorage:", error);

View File

@@ -4,6 +4,7 @@
import { useCallback, useState, useEffect } from "react";
import type { WidgetPlacement, LayoutConfig } from "@mosaic/shared";
import { safeJsonParse, isLayoutConfigRecord } from "@/lib/utils/safe-json";
const STORAGE_KEY = "mosaic-layout";
const DEFAULT_LAYOUT_NAME = "default";
@@ -37,13 +38,14 @@ export function useLayout(): UseLayoutReturn {
const [currentLayoutId, setCurrentLayoutId] = useState<string>(DEFAULT_LAYOUT_NAME);
const [isLoading, setIsLoading] = useState(true);
// Load layouts from localStorage on mount
// Load layouts from localStorage on mount with runtime type validation
useEffect(() => {
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
const parsed = JSON.parse(stored) as Record<string, LayoutConfig>;
setLayouts(parsed);
const emptyFallback: Record<string, LayoutConfig> = {};
const parsed = safeJsonParse(stored, isLayoutConfigRecord, emptyFallback);
setLayouts(parsed as Record<string, LayoutConfig>);
}
// Load current layout ID preference

View File

@@ -0,0 +1,291 @@
/**
* @file safe-json.test.ts
* @description Tests for safe JSON parsing utilities with runtime type validation
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import {
safeJsonParse,
isMessageArray,
isChatOverlayState,
isLayoutConfigRecord,
} from "./safe-json";
describe("safeJsonParse", () => {
beforeEach(() => {
vi.restoreAllMocks();
});
it("should return parsed data when JSON is valid and passes validation", () => {
const json = '{"key": "value"}';
const validator = (data: unknown): data is { key: string } =>
typeof data === "object" && data !== null && "key" in data;
const result = safeJsonParse(json, validator, { key: "fallback" });
expect(result).toEqual({ key: "value" });
});
it("should return fallback when JSON is invalid", () => {
const json = "not valid json {{{";
const validator = (data: unknown): data is string => typeof data === "string";
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined);
const result = safeJsonParse(json, validator, "fallback");
expect(result).toBe("fallback");
expect(warnSpy).toHaveBeenCalledWith("safeJsonParse: failed to parse JSON, returning fallback");
});
it("should return fallback when parsed data fails validation", () => {
const json = '{"wrong": "shape"}';
const validator = (data: unknown): data is { expected: string } =>
typeof data === "object" && data !== null && "expected" in data;
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined);
const result = safeJsonParse(json, validator, { expected: "default" });
expect(result).toEqual({ expected: "default" });
expect(warnSpy).toHaveBeenCalledWith(
"safeJsonParse: parsed data failed validation, returning fallback"
);
});
it("should return fallback for empty string", () => {
const validator = (data: unknown): data is string => typeof data === "string";
vi.spyOn(console, "warn").mockImplementation(() => undefined);
const result = safeJsonParse("", validator, "fallback");
expect(result).toBe("fallback");
});
it("should handle null JSON value gracefully", () => {
const validator = (data: unknown): data is object => typeof data === "object" && data !== null;
vi.spyOn(console, "warn").mockImplementation(() => undefined);
const result = safeJsonParse("null", validator, {});
expect(result).toEqual({});
});
});
describe("isMessageArray", () => {
it("should return true for a valid message array", () => {
const messages = [
{
id: "msg-1",
role: "user",
content: "Hello",
createdAt: "2026-01-01T00:00:00Z",
},
{
id: "msg-2",
role: "assistant",
content: "Hi there!",
createdAt: "2026-01-01T00:00:01Z",
},
];
expect(isMessageArray(messages)).toBe(true);
});
it("should return true for an empty array", () => {
expect(isMessageArray([])).toBe(true);
});
it("should return true for messages with optional fields", () => {
const messages = [
{
id: "msg-1",
role: "assistant",
content: "Response",
createdAt: "2026-01-01T00:00:00Z",
model: "llama3.2",
thinking: "Let me think...",
promptTokens: 10,
completionTokens: 5,
totalTokens: 15,
},
];
expect(isMessageArray(messages)).toBe(true);
});
it("should return false for non-array values", () => {
expect(isMessageArray(null)).toBe(false);
expect(isMessageArray(undefined)).toBe(false);
expect(isMessageArray("string")).toBe(false);
expect(isMessageArray(42)).toBe(false);
expect(isMessageArray({})).toBe(false);
});
it("should return false when message is missing required fields", () => {
// Missing id
expect(isMessageArray([{ role: "user", content: "Hello", createdAt: "2026-01-01" }])).toBe(
false
);
// Missing role
expect(isMessageArray([{ id: "1", content: "Hello", createdAt: "2026-01-01" }])).toBe(false);
// Missing content
expect(isMessageArray([{ id: "1", role: "user", createdAt: "2026-01-01" }])).toBe(false);
// Missing createdAt
expect(isMessageArray([{ id: "1", role: "user", content: "Hello" }])).toBe(false);
});
it("should return false when role is not a valid enum value", () => {
const messages = [
{
id: "msg-1",
role: "invalid-role",
content: "Hello",
createdAt: "2026-01-01T00:00:00Z",
},
];
expect(isMessageArray(messages)).toBe(false);
});
it("should return false when fields have wrong types", () => {
const messages = [
{
id: 123, // should be string
role: "user",
content: "Hello",
createdAt: "2026-01-01",
},
];
expect(isMessageArray(messages)).toBe(false);
});
it("should return false for array with mixed valid and invalid messages", () => {
const messages = [
{ id: "1", role: "user", content: "Hello", createdAt: "2026-01-01" },
{ invalid: "message" },
];
expect(isMessageArray(messages)).toBe(false);
});
});
describe("isChatOverlayState", () => {
it("should return true for a valid ChatOverlayState", () => {
expect(isChatOverlayState({ isOpen: true, isMinimized: false })).toBe(true);
expect(isChatOverlayState({ isOpen: false, isMinimized: true })).toBe(true);
});
it("should return false for non-object values", () => {
expect(isChatOverlayState(null)).toBe(false);
expect(isChatOverlayState(undefined)).toBe(false);
expect(isChatOverlayState("string")).toBe(false);
expect(isChatOverlayState(42)).toBe(false);
expect(isChatOverlayState([])).toBe(false);
});
it("should return false when fields are missing", () => {
expect(isChatOverlayState({ isOpen: true })).toBe(false);
expect(isChatOverlayState({ isMinimized: false })).toBe(false);
expect(isChatOverlayState({})).toBe(false);
});
it("should return false when fields have wrong types", () => {
expect(isChatOverlayState({ isOpen: "true", isMinimized: false })).toBe(false);
expect(isChatOverlayState({ isOpen: true, isMinimized: 0 })).toBe(false);
expect(isChatOverlayState({ isOpen: 1, isMinimized: 0 })).toBe(false);
});
it("should return true when extra fields are present", () => {
expect(isChatOverlayState({ isOpen: true, isMinimized: false, extra: "field" })).toBe(true);
});
});
describe("isLayoutConfigRecord", () => {
it("should return true for a valid layout config record", () => {
const record = {
default: {
id: "default",
name: "Default Layout",
layout: [],
},
custom: {
id: "custom",
name: "Custom Layout",
layout: [
{ i: "widget-1", x: 0, y: 0, w: 2, h: 2 },
{ i: "widget-2", x: 2, y: 0, w: 3, h: 1 },
],
},
};
expect(isLayoutConfigRecord(record)).toBe(true);
});
it("should return true for an empty record", () => {
expect(isLayoutConfigRecord({})).toBe(true);
});
it("should return false for non-object values", () => {
expect(isLayoutConfigRecord(null)).toBe(false);
expect(isLayoutConfigRecord(undefined)).toBe(false);
expect(isLayoutConfigRecord("string")).toBe(false);
expect(isLayoutConfigRecord(42)).toBe(false);
expect(isLayoutConfigRecord([])).toBe(false);
});
it("should return false when layout config is missing required fields", () => {
// Missing id
expect(isLayoutConfigRecord({ layout1: { name: "Test", layout: [] } })).toBe(false);
// Missing name
expect(isLayoutConfigRecord({ layout1: { id: "1", layout: [] } })).toBe(false);
// Missing layout
expect(isLayoutConfigRecord({ layout1: { id: "1", name: "Test" } })).toBe(false);
});
it("should return false when layout array contains invalid widget placements", () => {
const record = {
test: {
id: "test",
name: "Test",
layout: [{ i: "widget-1", x: 0 }], // missing y, w, h
},
};
expect(isLayoutConfigRecord(record)).toBe(false);
});
it("should return false when widget placement fields have wrong types", () => {
const record = {
test: {
id: "test",
name: "Test",
layout: [{ i: 123, x: 0, y: 0, w: 2, h: 2 }], // i should be string
},
};
expect(isLayoutConfigRecord(record)).toBe(false);
});
it("should return true with widget placements that have optional fields", () => {
const record = {
test: {
id: "test",
name: "Test",
layout: [
{
i: "widget-1",
x: 0,
y: 0,
w: 2,
h: 2,
minW: 1,
maxW: 4,
static: true,
},
],
},
};
expect(isLayoutConfigRecord(record)).toBe(true);
});
});

View File

@@ -0,0 +1,125 @@
/**
* @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);
}