From c1c621706c71ff4c37b2a8239c87032ed5afc631 Mon Sep 17 00:00:00 2001 From: TurtleWolfe Date: Sun, 2 Aug 2026 10:15:06 -0400 Subject: [PATCH 1/4] refactor(messaging): delete the dead realtime island (4 modules, never wired) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit useConversationRealtime, useConversationRealtimeSync, decrypt-message and decryption-cache formed a closed island: they imported each other and nothing outside imported any of them. Verified beyond static imports — no dynamic import() with a matching specifier, no string-built module paths. Born dead, not orphaned. useConversationRealtime was present in the initial commit (893f552) and page.tsx in that same commit already had the full-refetch loadMessages. `git log --all -S` over src/app/ and src/components/ returns empty across all history, with a control query confirming the search works. It had already cost real time twice. On 2026-03-24 a5d4e3d added 55 lines of polling fallback into the dead hook to fix CI flake; it fixed nothing, and 812cead hand-copied the logic into page.tsx twelve hours later ("was dead code"). More recently it produced a false root cause on #69, whose diagnosis named clearDecryptionCaches() — a function that cannot execute. No harvest was needed after all. The two candidates in #92 both turned out moot: upsertMessage's id-based dedupe cannot replace page.tsx's content heuristic while optimistic rows carry `optimistic-` ids and server rows carry UUIDs (blocked on #91), and the live path already has all three failure strings (message-service.ts:767, :859, plus EncryptionLockedError). Refs #92, #90, #69 Co-Authored-By: Claude Opus 5 (1M context) --- .../__tests__/useConversationRealtime.test.ts | 660 ------------------ src/hooks/useConversationRealtime.ts | 206 ------ src/hooks/useConversationRealtimeSync.ts | 144 ---- .../__tests__/decrypt-message.test.ts | 312 --------- .../__tests__/decryption-cache.test.ts | 145 ---- src/lib/messaging/decrypt-message.ts | 267 ------- src/lib/messaging/decryption-cache.ts | 102 --- 7 files changed, 1836 deletions(-) delete mode 100644 src/hooks/__tests__/useConversationRealtime.test.ts delete mode 100644 src/hooks/useConversationRealtime.ts delete mode 100644 src/hooks/useConversationRealtimeSync.ts delete mode 100644 src/lib/messaging/__tests__/decrypt-message.test.ts delete mode 100644 src/lib/messaging/__tests__/decryption-cache.test.ts delete mode 100644 src/lib/messaging/decrypt-message.ts delete mode 100644 src/lib/messaging/decryption-cache.ts diff --git a/src/hooks/__tests__/useConversationRealtime.test.ts b/src/hooks/__tests__/useConversationRealtime.test.ts deleted file mode 100644 index 822f6a88..00000000 --- a/src/hooks/__tests__/useConversationRealtime.test.ts +++ /dev/null @@ -1,660 +0,0 @@ -/** - * Unit Tests for useConversationRealtime Hook - * Task: T121 - * - * Tests real-time conversation management hook with mocked Supabase client. - */ - -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { renderHook, waitFor, act } from '@testing-library/react'; -import { useConversationRealtime } from '../useConversationRealtime'; -import { realtimeService } from '@/lib/messaging/realtime'; -import { messageService } from '@/services/messaging/message-service'; -import { keyManagementService } from '@/services/messaging/key-service'; -import { encryptionService } from '@/lib/messaging/encryption'; -import { createClient } from '@/lib/supabase/client'; - -// Mock dependencies -vi.mock('@/lib/supabase/client'); -vi.mock('@/lib/messaging/realtime'); -vi.mock('@/services/messaging/message-service'); -vi.mock('@/lib/messaging/encryption'); -vi.mock('@/services/messaging/key-service'); - -describe('useConversationRealtime', () => { - const mockConversationId = 'test-conversation-id'; - const mockUserId = 'test-user-id'; - const mockMessages = [ - { - id: 'msg-1', - conversation_id: mockConversationId, - sender_id: mockUserId, - content: 'Test message 1', - sequence_number: 1, - deleted: false, - edited: false, - edited_at: null, - delivered_at: null, - read_at: null, - created_at: new Date().toISOString(), - isOwn: true, - senderName: 'Test User', - }, - { - id: 'msg-2', - conversation_id: mockConversationId, - sender_id: 'other-user-id', - content: 'Test message 2', - sequence_number: 2, - deleted: false, - edited: false, - edited_at: null, - delivered_at: null, - read_at: null, - created_at: new Date().toISOString(), - isOwn: false, - senderName: 'Other User', - }, - ]; - - let mockSupabase: any; - let mockUnsubscribeMessages: ReturnType; - let mockUnsubscribeUpdates: ReturnType; - - beforeEach(() => { - // Mock Supabase client - mockSupabase = { - auth: { - getUser: vi.fn().mockResolvedValue({ - data: { user: { id: mockUserId } }, - error: null, - }), - }, - from: vi.fn().mockReturnThis(), - select: vi.fn().mockReturnThis(), - eq: vi.fn().mockReturnThis(), - single: vi.fn().mockResolvedValue({ - data: { - participant_1_id: mockUserId, - participant_2_id: 'other-user-id', - }, - error: null, - }), - }; - - (createClient as any).mockReturnValue(mockSupabase); - - // Mock message service - (messageService.getMessageHistory as any).mockResolvedValue({ - messages: mockMessages, - has_more: false, - cursor: null, - }); - - (messageService.sendMessage as any).mockResolvedValue({ - success: true, - message: mockMessages[0], - }); - - // Mock realtime service - mockUnsubscribeMessages = vi.fn(); - mockUnsubscribeUpdates = vi.fn(); - - (realtimeService.subscribeToMessages as any).mockReturnValue( - mockUnsubscribeMessages - ); - (realtimeService.subscribeToMessageUpdates as any).mockReturnValue( - mockUnsubscribeUpdates - ); - (realtimeService.unsubscribeFromConversation as any) = vi.fn(); - }); - - afterEach(() => { - vi.clearAllMocks(); - }); - - it('should load messages on mount', async () => { - const { result } = renderHook(() => - useConversationRealtime(mockConversationId) - ); - - // Initial loading state - expect(result.current.loading).toBe(true); - expect(result.current.messages).toEqual([]); - - // Wait for messages to load - await waitFor(() => { - expect(result.current.loading).toBe(false); - }); - - expect(result.current.messages).toEqual(mockMessages); - expect(result.current.error).toBeNull(); - }); - - it('should subscribe to realtime messages on mount with reconnect handler', async () => { - renderHook(() => useConversationRealtime(mockConversationId)); - - await waitFor(() => { - expect(realtimeService.subscribeToMessages).toHaveBeenCalledWith( - mockConversationId, - expect.any(Function), - expect.any(Function), // onReconnect callback - expect.any(Function) // onSubscribed callback (E2E DOM attribute) - ); - }); - - expect(realtimeService.subscribeToMessageUpdates).toHaveBeenCalledWith( - mockConversationId, - expect.any(Function) - ); - }); - - it('should unsubscribe from realtime on unmount', async () => { - const { unmount } = renderHook(() => - useConversationRealtime(mockConversationId) - ); - - await waitFor(() => { - expect(realtimeService.subscribeToMessages).toHaveBeenCalled(); - }); - - unmount(); - - expect(mockUnsubscribeMessages).toHaveBeenCalled(); - expect(mockUnsubscribeUpdates).toHaveBeenCalled(); - expect(realtimeService.unsubscribeFromConversation).toHaveBeenCalledWith( - mockConversationId - ); - }); - - it('should send message', async () => { - const { result } = renderHook(() => - useConversationRealtime(mockConversationId) - ); - - await waitFor(() => { - expect(result.current.loading).toBe(false); - }); - - await act(async () => { - await result.current.sendMessage('New test message'); - }); - - expect(messageService.sendMessage).toHaveBeenCalledWith({ - conversation_id: mockConversationId, - content: 'New test message', - }); - }); - - it('should handle pagination (loadMore)', async () => { - const olderMessages = [ - { - id: 'msg-0', - conversation_id: mockConversationId, - sender_id: 'other-user-id', - content: 'Older message', - sequence_number: 0, - deleted: false, - edited: false, - edited_at: null, - delivered_at: null, - read_at: null, - created_at: new Date(Date.now() - 10000).toISOString(), - isOwn: false, - senderName: 'Other User', - }, - ]; - - // First call returns messages with hasMore=true - (messageService.getMessageHistory as any) - .mockResolvedValueOnce({ - messages: mockMessages, - has_more: true, - cursor: 2, - }) - .mockResolvedValueOnce({ - messages: olderMessages, - has_more: false, - cursor: null, - }); - - const { result } = renderHook(() => - useConversationRealtime(mockConversationId) - ); - - await waitFor(() => { - expect(result.current.loading).toBe(false); - }); - - expect(result.current.hasMore).toBe(true); - - // Load more messages - await act(async () => { - await result.current.loadMore(); - }); - - // Should prepend older messages - expect(result.current.messages).toHaveLength(3); - expect(result.current.messages[0].id).toBe('msg-0'); - expect(result.current.hasMore).toBe(false); - }); - - it('should handle errors', async () => { - const mockError = new Error('Failed to load messages'); - (messageService.getMessageHistory as any).mockRejectedValueOnce(mockError); - - const { result } = renderHook(() => - useConversationRealtime(mockConversationId) - ); - - await waitFor(() => { - expect(result.current.loading).toBe(false); - }); - - expect(result.current.error).toEqual(mockError); - expect(result.current.messages).toEqual([]); - }); - - it('should add new message from realtime subscription', async () => { - let realtimeCallback: ((message: any) => void) | undefined; - - (realtimeService.subscribeToMessages as any).mockImplementation( - (_id: string, callback: (message: any) => void) => { - realtimeCallback = callback; - return vi.fn(); - } - ); - - const { result } = renderHook(() => - useConversationRealtime(mockConversationId) - ); - - await waitFor(() => { - expect(result.current.loading).toBe(false); - }); - - expect(result.current.messages).toHaveLength(2); - - // Simulate new message from realtime - const newMessage = { - id: 'msg-3', - conversation_id: mockConversationId, - sender_id: 'other-user-id', - encrypted_content: 'encrypted-content', - initialization_vector: 'iv', - sequence_number: 3, - deleted: false, - edited: false, - edited_at: null, - delivered_at: null, - read_at: null, - created_at: new Date().toISOString(), - }; - - await act(async () => { - if (realtimeCallback) { - realtimeCallback(newMessage); - } - }); - - // Note: In real implementation, message would be decrypted first - // This test verifies the subscription callback is set up correctly - expect(realtimeCallback).toBeDefined(); - }); - - it('should update message from realtime subscription', async () => { - let realtimeUpdateCallback: - | ((newMessage: any, oldMessage: any) => void) - | undefined; - - (realtimeService.subscribeToMessageUpdates as any).mockImplementation( - (_id: string, callback: (newMessage: any, oldMessage: any) => void) => { - realtimeUpdateCallback = callback; - return vi.fn(); - } - ); - - const { result } = renderHook(() => - useConversationRealtime(mockConversationId) - ); - - await waitFor(() => { - expect(result.current.loading).toBe(false); - }); - - // Simulate message update from realtime - const updatedMessage = { - ...mockMessages[0], - encrypted_content: 'updated-encrypted-content', - edited: true, - edited_at: new Date().toISOString(), - }; - - await act(async () => { - if (realtimeUpdateCallback) { - realtimeUpdateCallback(updatedMessage, mockMessages[0]); - } - }); - - expect(realtimeUpdateCallback).toBeDefined(); - }); - - it('should not load more if already loading', async () => { - (messageService.getMessageHistory as any).mockResolvedValue({ - messages: mockMessages, - has_more: true, - cursor: 2, - }); - - const { result } = renderHook(() => - useConversationRealtime(mockConversationId) - ); - - await waitFor(() => { - expect(result.current.loading).toBe(false); - }); - - // Trigger loadMore twice quickly within act to properly batch state updates - await act(async () => { - const promise1 = result.current.loadMore(); - const promise2 = result.current.loadMore(); - await Promise.all([promise1, promise2]); - }); - - // Should only call getMessageHistory twice (initial + one loadMore) - expect(messageService.getMessageHistory).toHaveBeenCalledTimes(2); - }); - - it('should not load more if no more messages', async () => { - (messageService.getMessageHistory as any).mockResolvedValue({ - messages: mockMessages, - has_more: false, - cursor: null, - }); - - const { result } = renderHook(() => - useConversationRealtime(mockConversationId) - ); - - await waitFor(() => { - expect(result.current.loading).toBe(false); - }); - - expect(result.current.hasMore).toBe(false); - - await act(async () => { - await result.current.loadMore(); - }); - - // Should only call once (initial load) - expect(messageService.getMessageHistory).toHaveBeenCalledTimes(1); - }); - - // ====================================================================== - // Bug fix regression tests - // ====================================================================== - - it('Bug #1: should deduplicate messages from realtime subscription', async () => { - let realtimeCallback: ((message: any) => void) | undefined; - - (realtimeService.subscribeToMessages as any).mockImplementation( - ( - _id: string, - callback: (message: any) => void, - _onReconnect?: () => void - ) => { - realtimeCallback = callback; - return vi.fn(); - } - ); - - const { result } = renderHook(() => - useConversationRealtime(mockConversationId) - ); - - await waitFor(() => { - expect(result.current.loading).toBe(false); - }); - - expect(result.current.messages).toHaveLength(2); - - // Simulate realtime delivering a message that was already loaded (e.g. after reconnect) - // The decryptSingleMessage mock returns null, so we can't test the full flow here, - // but we verify the callback exists and deduplication logic is in place - expect(realtimeCallback).toBeDefined(); - - // Directly test deduplication via the state setter: calling loadMessages again - // should replace, not duplicate, existing messages - await act(async () => { - // Trigger a second loadMessages (simulating reconnect catch-up) - (messageService.getMessageHistory as any).mockResolvedValueOnce({ - messages: mockMessages, // Same messages - has_more: false, - cursor: null, - }); - }); - - // Messages should not be duplicated - expect(result.current.messages).toHaveLength(2); - }); - - it('Bug #2: should pass onReconnect callback to subscribeToMessages', async () => { - let onReconnectCallback: (() => void) | undefined; - - (realtimeService.subscribeToMessages as any).mockImplementation( - (_id: string, _callback: any, onReconnect?: () => void) => { - onReconnectCallback = onReconnect; - return vi.fn(); - } - ); - - renderHook(() => useConversationRealtime(mockConversationId)); - - await waitFor(() => { - expect(onReconnectCallback).toBeDefined(); - }); - - // Reset call count - (messageService.getMessageHistory as any).mockClear(); - (messageService.getMessageHistory as any).mockResolvedValue({ - messages: mockMessages, - has_more: false, - cursor: null, - }); - - // Simulate reconnection by calling the callback - await act(async () => { - onReconnectCallback!(); - }); - - // Should have refetched messages on reconnect - expect(messageService.getMessageHistory).toHaveBeenCalledWith( - mockConversationId - ); - }); - - it('Bug #3: should add sent message to state immediately (optimistic update)', async () => { - const sentMessage = { - id: 'msg-new', - conversation_id: mockConversationId, - sender_id: mockUserId, - encrypted_content: 'encrypted', - initialization_vector: 'iv', - sequence_number: 3, - deleted: false, - edited: false, - edited_at: null, - delivered_at: null, - read_at: null, - created_at: new Date().toISOString(), - key_version: 1, - is_system_message: false, - system_message_type: null, - }; - - (messageService.sendMessage as any).mockResolvedValue({ - message: sentMessage, - queued: false, - }); - - const { result } = renderHook(() => - useConversationRealtime(mockConversationId) - ); - - await waitFor(() => { - expect(result.current.loading).toBe(false); - }); - - const initialCount = result.current.messages.length; - - await act(async () => { - await result.current.sendMessage('Hello world'); - }); - - // Sent message should appear immediately via optimistic update - expect(result.current.messages.length).toBe(initialCount + 1); - const lastMsg = result.current.messages[result.current.messages.length - 1]; - expect(lastMsg.id).toBe('msg-new'); - expect(lastMsg.content).toBe('Hello world'); - expect(lastMsg.isOwn).toBe(true); - }); - - it('Bug #5: should sort messages by sequence_number after realtime delivery', async () => { - // Start with messages at seq 1 and 3 (gap at 2) - const gappedMessages = [ - { ...mockMessages[0], sequence_number: 1 }, - { ...mockMessages[1], sequence_number: 3 }, - ]; - - (messageService.getMessageHistory as any).mockResolvedValue({ - messages: gappedMessages, - has_more: false, - cursor: null, - }); - - const sentMessage = { - id: 'msg-seq2', - conversation_id: mockConversationId, - sender_id: mockUserId, - encrypted_content: 'encrypted', - initialization_vector: 'iv', - sequence_number: 2, - deleted: false, - edited: false, - edited_at: null, - delivered_at: null, - read_at: null, - created_at: new Date().toISOString(), - key_version: 1, - is_system_message: false, - system_message_type: null, - }; - - (messageService.sendMessage as any).mockResolvedValue({ - message: sentMessage, - queued: false, - }); - - const { result } = renderHook(() => - useConversationRealtime(mockConversationId) - ); - - await waitFor(() => { - expect(result.current.loading).toBe(false); - }); - - // Send message with sequence_number=2 (should be inserted between 1 and 3) - await act(async () => { - await result.current.sendMessage('Middle message'); - }); - - // Verify messages are sorted by sequence_number - const seqNumbers = result.current.messages.map((m) => m.sequence_number); - expect(seqNumbers).toEqual([1, 2, 3]); - }); - - it('Bug fix: should show placeholder for undecryptable realtime messages instead of dropping them', async () => { - let realtimeCallback: ((message: any) => void) | undefined; - - (realtimeService.subscribeToMessages as any).mockImplementation( - ( - _id: string, - callback: (message: any) => void, - _onReconnect?: () => void - ) => { - realtimeCallback = callback; - return vi.fn(); - } - ); - - // Mock supabase auth to return a user (so decryptSingleMessage doesn't return null at the !user check) - const mockSupabaseWithConv = { - auth: { - getUser: vi.fn().mockResolvedValue({ - data: { user: { id: mockUserId } }, - error: null, - }), - }, - from: vi.fn().mockReturnValue({ - select: vi.fn().mockReturnValue({ - eq: vi.fn().mockReturnValue({ - single: vi.fn().mockResolvedValue({ - data: { - participant_1_id: mockUserId, - participant_2_id: 'other-user-id', - }, - error: null, - }), - }), - }), - }), - }; - (createClient as any).mockReturnValue(mockSupabaseWithConv); - - // Keys unavailable — ensureKeys returns null, triggering the placeholder path - (keyManagementService.ensureKeys as any).mockResolvedValue(null); - - const { result } = renderHook(() => - useConversationRealtime(mockConversationId) - ); - - await waitFor(() => { - expect(result.current.loading).toBe(false); - }); - - // Simulate realtime delivering an encrypted message that can't be decrypted - const encryptedMessage = { - id: 'msg-undecryptable', - conversation_id: mockConversationId, - sender_id: 'other-user-id', - encrypted_content: 'encrypted-content', - initialization_vector: 'iv', - sequence_number: 99, - deleted: false, - edited: false, - edited_at: null, - delivered_at: null, - read_at: null, - created_at: new Date().toISOString(), - key_version: 1, - is_system_message: false, - system_message_type: null, - }; - - await act(async () => { - if (realtimeCallback) { - realtimeCallback(encryptedMessage); - } - // Allow async decryptSingleMessage to complete - await new Promise((r) => setTimeout(r, 50)); - }); - - // The message should appear with decryptionError, NOT be silently dropped - const undecryptableMsg = result.current.messages.find( - (m) => m.id === 'msg-undecryptable' - ); - expect(undecryptableMsg).toBeDefined(); - expect(undecryptableMsg?.decryptionError).toBe(true); - expect(undecryptableMsg?.content).toContain('Unable to decrypt'); - }); -}); diff --git a/src/hooks/useConversationRealtime.ts b/src/hooks/useConversationRealtime.ts deleted file mode 100644 index c17cd097..00000000 --- a/src/hooks/useConversationRealtime.ts +++ /dev/null @@ -1,206 +0,0 @@ -'use client'; - -/** - * useConversationRealtime Hook - * Task: T112 - * - * Manages real-time message subscriptions and state for a conversation. - * Delegates decryption to decrypt-message and channel lifecycle to - * useConversationRealtimeSync. - */ - -import { useState, useCallback, useRef } from 'react'; -import { messageService } from '@/services/messaging/message-service'; -import type { - DecryptedMessage, - UseConversationRealtimeReturn, -} from '@/types/messaging'; -import { createClient } from '@/lib/supabase/client'; -import { getProfile } from '@/lib/messaging/decryption-cache'; -import { - decryptMessage, - type ConversationDataRef, -} from '@/lib/messaging/decrypt-message'; -import { useConversationRealtimeSync } from './useConversationRealtimeSync'; - -export function useConversationRealtime( - conversationId: string -): UseConversationRealtimeReturn { - const [messages, setMessages] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [hasMore, setHasMore] = useState(false); - const [cursor, setCursor] = useState(null); - const supabase = createClient(); - - const isMountedRef = useRef(true); - const loadingRef = useRef(false); - const conversationDataRef = useRef(null); - - // --- Data loading ---------------------------------------------------------- - - const loadMessages = useCallback(async () => { - try { - setLoading(true); - const result = await messageService.getMessageHistory(conversationId); - - if (isMountedRef.current) { - setMessages(result.messages); - setHasMore(result.has_more); - setCursor(result.cursor); - setError(null); - } - } catch (err) { - if (isMountedRef.current) { - setError(err as Error); - } - } finally { - if (isMountedRef.current) { - setLoading(false); - } - } - }, [conversationId]); - - const loadMore = useCallback(async () => { - if (!hasMore || loading || loadingRef.current) return; - - loadingRef.current = true; - try { - setLoading(true); - const result = await messageService.getMessageHistory( - conversationId, - cursor, - 50 - ); - - if (isMountedRef.current) { - setMessages((prev) => [...result.messages, ...prev]); - setHasMore(result.has_more); - setCursor(result.cursor); - } - } catch (err) { - if (isMountedRef.current) { - setError(err as Error); - } - } finally { - loadingRef.current = false; - if (isMountedRef.current) { - setLoading(false); - } - } - }, [conversationId, cursor, hasMore, loading]); - - // --- Realtime subscription (extracted hook) -------------------------------- - - useConversationRealtimeSync({ - conversationId, - supabase, - conversationDataRef, - loadMessages, - setMessages, - }); - - // --- Mutations ------------------------------------------------------------- - - /** - * Send a new message with optimistic update. - * Bug fix: adds message to state immediately instead of waiting for - * realtime subscription (which may not be ready for the first message). - */ - const sendMessage = useCallback( - async (content: string) => { - try { - const result = await messageService.sendMessage({ - conversation_id: conversationId, - content, - }); - - if (result.message && isMountedRef.current) { - const { - data: { user }, - } = await supabase.auth.getUser(); - - const senderProfile = user ? getProfile(user.id) : null; - - const optimisticMsg: DecryptedMessage = { - id: result.message.id, - conversation_id: conversationId, - sender_id: result.message.sender_id, - content, - sequence_number: result.message.sequence_number, - deleted: false, - edited: false, - edited_at: null, - delivered_at: result.message.delivered_at, - read_at: null, - created_at: result.message.created_at, - isOwn: true, - senderName: - senderProfile?.display_name || senderProfile?.username || 'You', - }; - - setMessages((prev) => { - if (prev.some((m) => m.id === optimisticMsg.id)) return prev; - return [...prev, optimisticMsg].sort( - (a, b) => a.sequence_number - b.sequence_number - ); - }); - } - } catch (err) { - setError(err as Error); - throw err; - } - }, - [conversationId, supabase] - ); - - /** - * Edit a message within 15-minute window (T105) - */ - const editMessage = useCallback( - async (message_id: string, new_content: string) => { - try { - await messageService.editMessage({ message_id, new_content }); - setMessages((prev) => - prev.map((msg) => - msg.id === message_id - ? { - ...msg, - content: new_content, - edited: true, - edited_at: new Date().toISOString(), - } - : msg - ) - ); - } catch (err) { - setError(err as Error); - throw err; - } - }, - [] - ); - - /** - * Delete a message within 15-minute window (T106) - */ - const deleteMessage = useCallback(async (message_id: string) => { - try { - await messageService.deleteMessage(message_id); - } catch (err) { - setError(err as Error); - throw err; - } - }, []); - - return { - messages, - loading, - error, - sendMessage, - editMessage, - deleteMessage, - loadMore, - hasMore, - }; -} diff --git a/src/hooks/useConversationRealtimeSync.ts b/src/hooks/useConversationRealtimeSync.ts deleted file mode 100644 index 18b037f1..00000000 --- a/src/hooks/useConversationRealtimeSync.ts +++ /dev/null @@ -1,144 +0,0 @@ -'use client'; - -/** - * useConversationRealtimeSync — realtime subscription lifecycle for a conversation. - * - * Extracted from useConversationRealtime to keep each hook focused. - * Owns: channel subscribe / unsubscribe, reconnection catch-up, dedup, ordering. - * - * Includes a 10-second polling fallback that catches messages silently dropped - * by Supabase Realtime under connection contention (e.g. free-tier limits). - */ - -import { useEffect, useCallback, useRef } from 'react'; -import type { SupabaseClient } from '@supabase/supabase-js'; -import { createLogger } from '@/lib/logger'; -import { realtimeService } from '@/lib/messaging/realtime'; -import type { Message, DecryptedMessage } from '@/types/messaging'; -import { - decryptMessage, - upsertMessage, - type ConversationDataRef, -} from '@/lib/messaging/decrypt-message'; - -const logger = createLogger('hooks:conversationRealtimeSync'); - -/** How often the polling fallback fires (ms). */ -const POLL_INTERVAL_MS = 10_000; - -export interface UseConversationRealtimeSyncOptions { - conversationId: string; - supabase: SupabaseClient; - conversationDataRef: ConversationDataRef; - /** Called once on mount + on reconnection to load/reload history. */ - loadMessages: () => Promise; - /** Setter to merge new/updated messages into state. */ - setMessages: React.Dispatch>; -} - -/** - * Subscribe to INSERT and UPDATE events on messages for a conversation. - * Handles dedup, ordering, reconnection catch-up, and periodic polling fallback. - */ -export function useConversationRealtimeSync({ - conversationId, - supabase, - conversationDataRef, - loadMessages, - setMessages, -}: UseConversationRealtimeSyncOptions): void { - const isMountedRef = useRef(true); - const lastRealtimeEventRef = useRef(0); - - const decryptSingle = useCallback( - async (msg: Message): Promise => - decryptMessage(msg, conversationId, supabase, conversationDataRef), - [conversationId, supabase, conversationDataRef] - ); - - // Mark that Realtime delivered something — resets the polling skip window - const touchRealtime = useCallback(() => { - lastRealtimeEventRef.current = Date.now(); - }, []); - - useEffect(() => { - isMountedRef.current = true; - - // Load initial messages - loadMessages(); - - // Subscribe to new messages (with reconnection catch-up) - const unsubscribeMessages = realtimeService.subscribeToMessages( - conversationId, - async (message) => { - touchRealtime(); - const decrypted = await decryptSingle(message); - if (decrypted && isMountedRef.current) { - setMessages((prev) => upsertMessage(prev, decrypted)); - } - }, - () => { - if (isMountedRef.current) { - logger.debug('Reconnected — refetching messages to catch up'); - loadMessages(); - } - }, - () => { - // Signal message subscription readiness via DOM attribute (used by E2E tests) - if (typeof document !== 'undefined' && conversationId) { - document.body.setAttribute( - 'data-messages-subscribed', - conversationId - ); - } - } - ); - - // Subscribe to message updates (edits/deletes) - const unsubscribeUpdates = realtimeService.subscribeToMessageUpdates( - conversationId, - async (newMessage) => { - touchRealtime(); - const decrypted = await decryptSingle(newMessage); - if (decrypted && isMountedRef.current) { - setMessages((prev) => upsertMessage(prev, decrypted)); - } - } - ); - - // ── Polling fallback ────────────────────────────────────────────── - // Supabase Realtime on the free tier can silently drop messages under - // connection contention. This interval re-fetches from the DB every - // POLL_INTERVAL_MS when Realtime hasn't delivered anything recently, - // guaranteeing eventual delivery even if the WebSocket missed an event. - const pollTimer = setInterval(() => { - if (!isMountedRef.current) return; - // Skip if tab is hidden (save API calls) - if (typeof document !== 'undefined' && document.hidden) return; - // Skip if Realtime delivered something within the last interval - if (Date.now() - lastRealtimeEventRef.current < POLL_INTERVAL_MS) return; - - logger.debug('Polling fallback — refetching messages'); - loadMessages().then(() => { - if (typeof document !== 'undefined') { - document.body.setAttribute( - 'data-messages-last-poll', - new Date().toISOString() - ); - } - }); - }, POLL_INTERVAL_MS); - - return () => { - isMountedRef.current = false; - unsubscribeMessages(); - unsubscribeUpdates(); - clearInterval(pollTimer); - realtimeService.unsubscribeFromConversation(conversationId); - if (typeof document !== 'undefined') { - document.body.removeAttribute('data-messages-subscribed'); - document.body.removeAttribute('data-messages-last-poll'); - } - }; - }, [conversationId, loadMessages, decryptSingle, setMessages, touchRealtime]); -} diff --git a/src/lib/messaging/__tests__/decrypt-message.test.ts b/src/lib/messaging/__tests__/decrypt-message.test.ts deleted file mode 100644 index ff1a6d82..00000000 --- a/src/lib/messaging/__tests__/decrypt-message.test.ts +++ /dev/null @@ -1,312 +0,0 @@ -/** - * Tests for decrypt-message module - * - * Covers: - * - Successful decryption path - * - Placeholder when keys are unavailable (makePlaceholder path) - * - isOwn flag after key rotation (catch block path) - * - Null return when user is not authenticated - */ - -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { decryptMessage, type ConversationDataRef } from '../decrypt-message'; -import type { Message } from '@/types/messaging'; - -// --- Mocks ------------------------------------------------------------------- - -const mockDecryptMessage = vi.fn(); -const mockDeriveSharedSecret = vi.fn(); - -vi.mock('@/lib/messaging/encryption', () => ({ - encryptionService: { - decryptMessage: (...args: unknown[]) => mockDecryptMessage(...args), - deriveSharedSecret: (...args: unknown[]) => mockDeriveSharedSecret(...args), - }, -})); - -const mockEnsureKeys = vi.fn(); -const mockGetUserPublicKey = vi.fn(); - -vi.mock('@/services/messaging/key-service', () => ({ - keyManagementService: { - ensureKeys: (...args: unknown[]) => mockEnsureKeys(...args), - getUserPublicKey: (...args: unknown[]) => mockGetUserPublicKey(...args), - }, -})); - -vi.mock('@/lib/supabase/messaging-client', () => ({ - createMessagingClient: () => ({ - from: () => ({ - select: () => ({ - eq: () => ({ - single: vi.fn().mockResolvedValue({ - data: { participant_1_id: 'user-1', participant_2_id: 'user-2' }, - error: null, - }), - }), - }), - }), - }), -})); - -const mockProfileStore = new Map< - string, - { username: string | null; display_name: string | null } ->(); - -vi.mock('@/lib/messaging/decryption-cache', () => { - const sharedSecretCache = new Map(); - const privateKeyCache = new Map(); - return { - sharedSecretCache, - privateKeyCache, - getProfile: (id: string) => mockProfileStore.get(id), - setProfile: ( - id: string, - data: { username: string | null; display_name: string | null } - ) => mockProfileStore.set(id, data), - invalidateProfile: (id: string) => mockProfileStore.delete(id), - deduplicateProfile: (_id: string, fn: () => Promise) => fn(), - deduplicateSecret: (_key: string, fn: () => Promise) => fn(), - }; -}); - -// Import the mock instances so we can clear them between tests -import { - sharedSecretCache, - privateKeyCache, -} from '@/lib/messaging/decryption-cache'; - -vi.mock('@/lib/logger', () => ({ - createLogger: () => ({ - debug: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - }), -})); - -// --- Helpers ----------------------------------------------------------------- - -const CURRENT_USER_ID = 'user-1'; -const OTHER_USER_ID = 'user-2'; - -function makeMessage(overrides?: Partial): Message { - return { - id: 'msg-1', - conversation_id: 'conv-1', - sender_id: OTHER_USER_ID, - encrypted_content: 'base64-ciphertext', - initialization_vector: 'base64-iv', - sequence_number: 1, - deleted: false, - edited: false, - edited_at: null, - delivered_at: null, - read_at: null, - created_at: '2026-03-19T00:00:00Z', - key_version: 1, - is_system_message: false, - system_message_type: null, - ...overrides, - }; -} - -function makeSupabase(userId: string | null = CURRENT_USER_ID) { - return { - auth: { - getUser: vi.fn().mockResolvedValue({ - data: { user: userId ? { id: userId } : null }, - error: null, - }), - }, - from: vi.fn().mockReturnValue({ - select: vi.fn().mockReturnValue({ - eq: vi.fn().mockReturnValue({ - single: vi.fn().mockResolvedValue({ - data: { username: 'alice', display_name: 'Alice' }, - error: null, - }), - }), - }), - }), - } as any; -} - -function makeRef(data?: ConversationDataRef['current']): ConversationDataRef { - return { current: data ?? null }; -} - -// --- Tests ------------------------------------------------------------------- - -describe('decryptMessage', () => { - beforeEach(() => { - vi.clearAllMocks(); - sharedSecretCache.clear(); - privateKeyCache.clear(); - mockProfileStore.clear(); - }); - - it('returns null when user is not authenticated', async () => { - const supabase = makeSupabase(null); - const result = await decryptMessage( - makeMessage(), - 'conv-1', - supabase, - makeRef({ - participant_1_id: CURRENT_USER_ID, - participant_2_id: OTHER_USER_ID, - }) - ); - expect(result).toBeNull(); - }); - - it('returns placeholder with correct isOwn when keys are unavailable', async () => { - mockEnsureKeys.mockResolvedValue(null); - - const ownMessage = makeMessage({ sender_id: CURRENT_USER_ID }); - const supabase = makeSupabase(); - const ref = makeRef({ - participant_1_id: CURRENT_USER_ID, - participant_2_id: OTHER_USER_ID, - }); - - const result = await decryptMessage(ownMessage, 'conv-1', supabase, ref); - - expect(result).not.toBeNull(); - expect(result!.decryptionError).toBe(true); - expect(result!.isOwn).toBe(true); - expect(result!.content).toContain('Unable to decrypt'); - }); - - it('returns placeholder with isOwn=false for other user when keys unavailable', async () => { - mockEnsureKeys.mockResolvedValue(null); - - const otherMessage = makeMessage({ sender_id: OTHER_USER_ID }); - const supabase = makeSupabase(); - const ref = makeRef({ - participant_1_id: CURRENT_USER_ID, - participant_2_id: OTHER_USER_ID, - }); - - const result = await decryptMessage(otherMessage, 'conv-1', supabase, ref); - - expect(result!.decryptionError).toBe(true); - expect(result!.isOwn).toBe(false); - }); - - // --- Key rotation scenario ------------------------------------------------- - - it('preserves isOwn=true in catch block when own message fails decryption (key rotation)', async () => { - // Simulate: keys are available but decryption fails (old message, new keys) - const mockPrivateKey = {} as CryptoKey; - mockEnsureKeys.mockResolvedValue({ privateKey: mockPrivateKey }); - mockGetUserPublicKey.mockResolvedValue({ kty: 'EC', crv: 'P-256' }); - - // crypto.subtle.importKey for the other user's public key - const mockImportedKey = {} as CryptoKey; - vi.spyOn(crypto.subtle, 'importKey').mockResolvedValue(mockImportedKey); - - // deriveSharedSecret returns a CryptoKey - const mockSharedSecret = {} as CryptoKey; - mockDeriveSharedSecret.mockResolvedValue(mockSharedSecret); - - // decryptMessage THROWS — simulating AES-GCM auth failure from wrong shared secret - mockDecryptMessage.mockRejectedValue( - new Error('The operation failed for an operation-specific reason') - ); - - const ownMessage = makeMessage({ sender_id: CURRENT_USER_ID }); - const supabase = makeSupabase(); - const ref = makeRef({ - participant_1_id: CURRENT_USER_ID, - participant_2_id: OTHER_USER_ID, - }); - - const result = await decryptMessage(ownMessage, 'conv-1', supabase, ref); - - expect(result).not.toBeNull(); - expect(result!.decryptionError).toBe(true); - expect(result!.content).toBe('Encrypted with previous keys'); - // This is the bug fix: isOwn must be true for own messages even after rotation - expect(result!.isOwn).toBe(true); - expect(result!.sender_id).toBe(CURRENT_USER_ID); - }); - - it('preserves isOwn=false in catch block for other user message after key rotation', async () => { - const mockPrivateKey = {} as CryptoKey; - mockEnsureKeys.mockResolvedValue({ privateKey: mockPrivateKey }); - mockGetUserPublicKey.mockResolvedValue({ kty: 'EC', crv: 'P-256' }); - - const mockImportedKey = {} as CryptoKey; - vi.spyOn(crypto.subtle, 'importKey').mockResolvedValue(mockImportedKey); - - const mockSharedSecret = {} as CryptoKey; - mockDeriveSharedSecret.mockResolvedValue(mockSharedSecret); - - // AES-GCM auth failure - mockDecryptMessage.mockRejectedValue( - new Error('The operation failed for an operation-specific reason') - ); - - const otherMessage = makeMessage({ sender_id: OTHER_USER_ID }); - const supabase = makeSupabase(); - const ref = makeRef({ - participant_1_id: CURRENT_USER_ID, - participant_2_id: OTHER_USER_ID, - }); - - const result = await decryptMessage(otherMessage, 'conv-1', supabase, ref); - - expect(result!.decryptionError).toBe(true); - expect(result!.content).toBe('Encrypted with previous keys'); - expect(result!.isOwn).toBe(false); - }); - - // --- Happy path ------------------------------------------------------------ - - it('decrypts successfully and returns correct isOwn for own message', async () => { - const mockPrivateKey = {} as CryptoKey; - mockEnsureKeys.mockResolvedValue({ privateKey: mockPrivateKey }); - mockGetUserPublicKey.mockResolvedValue({ kty: 'EC', crv: 'P-256' }); - - const mockImportedKey = {} as CryptoKey; - vi.spyOn(crypto.subtle, 'importKey').mockResolvedValue(mockImportedKey); - - const mockSharedSecret = {} as CryptoKey; - mockDeriveSharedSecret.mockResolvedValue(mockSharedSecret); - mockDecryptMessage.mockResolvedValue('Hello world'); - - const ownMessage = makeMessage({ sender_id: CURRENT_USER_ID }); - const supabase = makeSupabase(); - const ref = makeRef({ - participant_1_id: CURRENT_USER_ID, - participant_2_id: OTHER_USER_ID, - }); - - const result = await decryptMessage(ownMessage, 'conv-1', supabase, ref); - - expect(result).not.toBeNull(); - expect(result!.content).toBe('Hello world'); - expect(result!.isOwn).toBe(true); - expect(result!.decryptionError).toBeUndefined(); - }); - - it('returns placeholder with correct isOwn when other user has no public key', async () => { - const mockPrivateKey = {} as CryptoKey; - mockEnsureKeys.mockResolvedValue({ privateKey: mockPrivateKey }); - mockGetUserPublicKey.mockResolvedValue(null); // No public key - - const ownMessage = makeMessage({ sender_id: CURRENT_USER_ID }); - const supabase = makeSupabase(); - const ref = makeRef({ - participant_1_id: CURRENT_USER_ID, - participant_2_id: OTHER_USER_ID, - }); - - const result = await decryptMessage(ownMessage, 'conv-1', supabase, ref); - - expect(result!.decryptionError).toBe(true); - expect(result!.isOwn).toBe(true); - expect(result!.content).toContain('sender encryption keys unavailable'); - }); -}); diff --git a/src/lib/messaging/__tests__/decryption-cache.test.ts b/src/lib/messaging/__tests__/decryption-cache.test.ts deleted file mode 100644 index 4226ec2b..00000000 --- a/src/lib/messaging/__tests__/decryption-cache.test.ts +++ /dev/null @@ -1,145 +0,0 @@ -/** - * Tests for decryption-cache module - * - * Covers: - * - Profile TTL: cached profiles expire after PROFILE_TTL_MS - * - Profile invalidation: invalidateProfile() clears a single entry - * - Request deduplication: concurrent fetches coalesce into one call - * - clearDecryptionCaches() clears everything including pending maps - */ - -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { - sharedSecretCache, - privateKeyCache, - getProfile, - setProfile, - invalidateProfile, - deduplicateProfile, - deduplicateSecret, - clearDecryptionCaches, - PROFILE_TTL_MS, -} from '../decryption-cache'; - -describe('decryption-cache', () => { - beforeEach(() => { - clearDecryptionCaches(); - }); - - describe('profile TTL', () => { - it('returns cached profile within TTL window', () => { - setProfile('user-1', { username: 'alice', display_name: 'Alice' }); - const result = getProfile('user-1'); - expect(result).toEqual({ username: 'alice', display_name: 'Alice' }); - }); - - it('returns undefined for expired profile', () => { - setProfile('user-1', { username: 'alice', display_name: 'Alice' }); - - // Advance time past TTL - vi.useFakeTimers(); - vi.advanceTimersByTime(PROFILE_TTL_MS + 1); - - const result = getProfile('user-1'); - expect(result).toBeUndefined(); - - vi.useRealTimers(); - }); - - it('returns undefined for unknown sender', () => { - expect(getProfile('nonexistent')).toBeUndefined(); - }); - }); - - describe('invalidateProfile', () => { - it('removes a single profile entry immediately', () => { - setProfile('user-1', { username: 'alice', display_name: 'Alice' }); - setProfile('user-2', { username: 'bob', display_name: 'Bob' }); - - invalidateProfile('user-1'); - - expect(getProfile('user-1')).toBeUndefined(); - expect(getProfile('user-2')).toEqual({ - username: 'bob', - display_name: 'Bob', - }); - }); - }); - - describe('deduplicateProfile', () => { - it('coalesces concurrent calls for the same sender into one fetch', async () => { - const fetcher = vi - .fn() - .mockResolvedValue({ username: 'alice', display_name: 'Alice' }); - - // Fire 3 concurrent calls - const [r1, r2, r3] = await Promise.all([ - deduplicateProfile('user-1', fetcher), - deduplicateProfile('user-1', fetcher), - deduplicateProfile('user-1', fetcher), - ]); - - expect(fetcher).toHaveBeenCalledTimes(1); - expect(r1).toEqual({ username: 'alice', display_name: 'Alice' }); - expect(r2).toEqual({ username: 'alice', display_name: 'Alice' }); - expect(r3).toEqual({ username: 'alice', display_name: 'Alice' }); - }); - - it('allows a new fetch after the first completes', async () => { - const fetcher = vi - .fn() - .mockResolvedValue({ username: 'alice', display_name: 'Alice' }); - - await deduplicateProfile('user-1', fetcher); - await deduplicateProfile('user-1', fetcher); - - // Two sequential calls = two fetcher invocations (no pending overlap) - expect(fetcher).toHaveBeenCalledTimes(2); - }); - - it('cleans up pending entry even if fetcher throws', async () => { - const fetcher = vi.fn().mockRejectedValue(new Error('Network error')); - - await expect(deduplicateProfile('user-1', fetcher)).rejects.toThrow( - 'Network error' - ); - - // Pending map should be clean — next call should invoke fetcher again - const fetcher2 = vi - .fn() - .mockResolvedValue({ username: null, display_name: null }); - await deduplicateProfile('user-1', fetcher2); - expect(fetcher2).toHaveBeenCalledTimes(1); - }); - }); - - describe('deduplicateSecret', () => { - it('coalesces concurrent derivations for the same cache key', async () => { - const mockSecret = {} as CryptoKey; - const deriver = vi.fn().mockResolvedValue(mockSecret); - - const [r1, r2] = await Promise.all([ - deduplicateSecret('conv:user', deriver), - deduplicateSecret('conv:user', deriver), - ]); - - expect(deriver).toHaveBeenCalledTimes(1); - expect(r1).toBe(mockSecret); - expect(r2).toBe(mockSecret); - }); - }); - - describe('clearDecryptionCaches', () => { - it('clears all caches', () => { - sharedSecretCache.set('key', {} as CryptoKey); - privateKeyCache.set('user', {} as CryptoKey); - setProfile('user', { username: 'test', display_name: 'Test' }); - - clearDecryptionCaches(); - - expect(sharedSecretCache.size).toBe(0); - expect(privateKeyCache.size).toBe(0); - expect(getProfile('user')).toBeUndefined(); - }); - }); -}); diff --git a/src/lib/messaging/decrypt-message.ts b/src/lib/messaging/decrypt-message.ts deleted file mode 100644 index 84816cea..00000000 --- a/src/lib/messaging/decrypt-message.ts +++ /dev/null @@ -1,267 +0,0 @@ -/** - * decrypt-message — stateless (cache-aware) single-message decryption. - * - * Extracted from useConversationRealtime so the hook stays under 400 lines. - * Caches: conversation participants, private key, shared secret, sender profile. - */ - -import type { SupabaseClient } from '@supabase/supabase-js'; -import { createLogger } from '@/lib/logger'; -import { encryptionService } from '@/lib/messaging/encryption'; -import { keyManagementService } from '@/services/messaging/key-service'; -import { createMessagingClient } from '@/lib/supabase/messaging-client'; -import type { Message, DecryptedMessage } from '@/types/messaging'; -import { - sharedSecretCache, - privateKeyCache, - getProfile, - setProfile, - deduplicateProfile, - deduplicateSecret, -} from '@/lib/messaging/decryption-cache'; - -const logger = createLogger('messaging:decrypt-message'); - -/** Ref-like mutable holder so the caller can share conversation data across calls. */ -export interface ConversationDataRef { - current: { participant_1_id: string; participant_2_id: string } | null; -} - -/** Build a placeholder DecryptedMessage when decryption is impossible. */ -function makePlaceholder( - msg: Message, - userId: string, - reason: string -): DecryptedMessage { - return { - id: msg.id, - conversation_id: msg.conversation_id, - sender_id: msg.sender_id, - content: reason, - sequence_number: msg.sequence_number, - deleted: msg.deleted, - edited: msg.edited, - edited_at: msg.edited_at, - delivered_at: msg.delivered_at, - read_at: msg.read_at, - created_at: msg.created_at, - isOwn: msg.sender_id === userId, - senderName: 'Unknown', - decryptionError: true, - }; -} - -/** - * Decrypt a single Message → DecryptedMessage, using module-level caches - * for shared secret, private key, and sender profile. - */ -export async function decryptMessage( - msg: Message, - conversationId: string, - supabase: SupabaseClient, - conversationDataRef: ConversationDataRef -): Promise { - let userId: string | null = null; - try { - const { - data: { user }, - } = await supabase.auth.getUser(); - - if (!user) return null; - userId = user.id; - - // Get conversation details (cached in ref) - let conversation = conversationDataRef.current; - if (!conversation) { - const msgClient = createMessagingClient(supabase); - const result = await msgClient - .from('conversations') - .select('participant_1_id, participant_2_id') - .eq('id', conversationId) - .single(); - - conversation = result.data as { - participant_1_id: string; - participant_2_id: string; - } | null; - - if (!conversation) return null; - conversationDataRef.current = conversation; - } - - // Determine other participant - const otherParticipantId = - conversation.participant_1_id === user.id - ? conversation.participant_2_id - : conversation.participant_1_id; - - // Check shared secret cache first (most expensive operation) - const cacheKey = `${conversationId}:${otherParticipantId}`; - let sharedSecret = sharedSecretCache.get(cacheKey); - - if (!sharedSecret) { - const derived = await deduplicateSecret(cacheKey, async () => { - // Get private key from memory (derived during sign-in) - let privateKey = privateKeyCache.get(user.id); - if (!privateKey) { - const derivedKeys = await keyManagementService.ensureKeys(user.id); - if (!derivedKeys) { - return null; - } - privateKey = derivedKeys.privateKey; - privateKeyCache.set(user.id, privateKey); - } - - // Get other participant's public key - const otherPublicKey = - await keyManagementService.getUserPublicKey(otherParticipantId); - if (!otherPublicKey) return null; - - const otherPublicKeyCrypto = await crypto.subtle.importKey( - 'jwk', - otherPublicKey, - { name: 'ECDH', namedCurve: 'P-256' }, - false, - [] - ); - - const secret = await encryptionService.deriveSharedSecret( - privateKey, - otherPublicKeyCrypto - ); - sharedSecretCache.set(cacheKey, secret); - logger.debug('Cached shared secret for conversation', { - conversationId, - }); - return secret; - }); - - if (!derived) { - // Determine which step failed for the right placeholder message - const hasPrivateKey = - privateKeyCache.has(user.id) || - (await keyManagementService.ensureKeys(user.id)); - if (!hasPrivateKey) { - logger.warn( - 'No derived keys available - user may need to re-authenticate' - ); - return makePlaceholder( - msg, - user.id, - 'Unable to decrypt — please sign in again' - ); - } - logger.warn('Other participant has no public key', { - otherParticipantId, - }); - return makePlaceholder( - msg, - user.id, - 'Unable to decrypt — sender encryption keys unavailable' - ); - } - sharedSecret = derived; - } - - // Decrypt message (fast once we have shared secret) - const content = await encryptionService.decryptMessage( - msg.encrypted_content, - msg.initialization_vector, - sharedSecret - ); - - // Get sender profile (cached with TTL, deduplicated) - let senderProfile = getProfile(msg.sender_id); - if (!senderProfile) { - senderProfile = await deduplicateProfile(msg.sender_id, async () => { - const { data } = await supabase - .from('user_profiles') - .select('username, display_name') - .eq('id', msg.sender_id) - .single(); - - const profile = data || { username: null, display_name: null }; - setProfile(msg.sender_id, profile); - return profile; - }); - } - - return { - id: msg.id, - conversation_id: msg.conversation_id, - sender_id: msg.sender_id, - content, - sequence_number: msg.sequence_number, - deleted: msg.deleted, - edited: msg.edited, - edited_at: msg.edited_at, - delivered_at: msg.delivered_at, - read_at: msg.read_at, - created_at: msg.created_at, - isOwn: msg.sender_id === user.id, - senderName: - senderProfile?.display_name || senderProfile?.username || 'Unknown', - }; - } catch (err) { - logger.error('Failed to decrypt message', { error: err }); - return { - id: msg.id, - conversation_id: msg.conversation_id, - sender_id: msg.sender_id, - content: 'Encrypted with previous keys', - sequence_number: msg.sequence_number, - deleted: msg.deleted, - edited: msg.edited, - edited_at: msg.edited_at, - delivered_at: msg.delivered_at, - read_at: msg.read_at, - created_at: msg.created_at, - isOwn: userId !== null && msg.sender_id === userId, - senderName: 'Unknown', - decryptionError: true, - }; - } -} - -/** - * Upsert a decrypted message into the list: replace if the ID already - * exists, otherwise insert; then sort by sequence_number (falling back to - * created_at for seq=0 offline-queued placeholders). - * - * Idempotent by design — Realtime can redeliver on reconnect and we must - * not duplicate. Also refuses to regress a plaintext message back to an - * error placeholder if a later redelivery fails to decrypt. - */ -export function upsertMessage( - prev: DecryptedMessage[], - incoming: DecryptedMessage -): DecryptedMessage[] { - const idx = prev.findIndex((m) => m.id === incoming.id); - let next: DecryptedMessage[]; - - if (idx >= 0) { - const existing = prev[idx]; - // Don't downgrade plaintext → "could not decrypt" placeholder. - const merged = - incoming.decryptionError && !existing.decryptionError - ? { - ...incoming, - content: existing.content, - isOwn: existing.isOwn, - senderName: existing.senderName, - decryptionError: false, - } - : incoming; - next = [...prev]; - next[idx] = merged; - } else { - next = [...prev, incoming]; - } - - return next.sort((a, b) => { - if (a.sequence_number !== b.sequence_number) { - return a.sequence_number - b.sequence_number; - } - return new Date(a.created_at).getTime() - new Date(b.created_at).getTime(); - }); -} diff --git a/src/lib/messaging/decryption-cache.ts b/src/lib/messaging/decryption-cache.ts deleted file mode 100644 index e66efe35..00000000 --- a/src/lib/messaging/decryption-cache.ts +++ /dev/null @@ -1,102 +0,0 @@ -/** - * Module-level decryption caches for the messaging system. - * - * Extracted into a standalone module to avoid circular imports between - * key-service (which must clear caches on sign-out / key rotation) and - * useConversationRealtime (which populates and reads the caches). - */ - -/** How long a cached profile stays valid before re-fetching (10 minutes). */ -const PROFILE_CACHE_TTL_MS = 10 * 60 * 1000; - -export interface ProfileData { - username: string | null; - display_name: string | null; -} - -interface CachedProfile { - data: ProfileData; - cachedAt: number; -} - -// Shared secret cache: Key = `${conversationId}:${otherParticipantId}` -export const sharedSecretCache = new Map(); - -// Imported private key cache: Key = userId -export const privateKeyCache = new Map(); - -// User profile cache with TTL: Key = senderId -const profileCache = new Map(); - -// Pending request maps for deduplication of concurrent fetches -const pendingProfiles = new Map>(); -const pendingSecrets = new Map>(); - -/** Read a profile from cache, returning undefined if missing or expired. */ -export function getProfile(senderId: string): ProfileData | undefined { - const entry = profileCache.get(senderId); - if (!entry) return undefined; - if (Date.now() - entry.cachedAt > PROFILE_CACHE_TTL_MS) { - profileCache.delete(senderId); - return undefined; - } - return entry.data; -} - -/** Write a profile to cache with a timestamp. */ -export function setProfile(senderId: string, data: ProfileData): void { - profileCache.set(senderId, { data, cachedAt: Date.now() }); -} - -/** Immediately invalidate a single profile entry (e.g. after the current user updates their own name). */ -export function invalidateProfile(userId: string): void { - profileCache.delete(userId); -} - -/** - * Deduplicate concurrent profile fetches for the same sender. - * The first caller runs the fetcher; subsequent callers await the same Promise. - */ -export function deduplicateProfile( - senderId: string, - fetcher: () => Promise -): Promise { - const pending = pendingProfiles.get(senderId); - if (pending) return pending; - - const promise = fetcher().finally(() => pendingProfiles.delete(senderId)); - pendingProfiles.set(senderId, promise); - return promise; -} - -/** - * Deduplicate concurrent shared-secret derivations for the same conversation+participant. - * The first caller runs the deriver; subsequent callers await the same Promise. - */ -export function deduplicateSecret( - cacheKey: string, - deriver: () => Promise -): Promise { - const pending = pendingSecrets.get(cacheKey); - if (pending) return pending; - - const promise = deriver().finally(() => pendingSecrets.delete(cacheKey)); - pendingSecrets.set(cacheKey, promise); - return promise; -} - -/** - * Clear all module-level decryption caches. - * Must be called on sign-out and key rotation so stale shared secrets - * derived from old key pairs are never reused. - */ -export function clearDecryptionCaches(): void { - sharedSecretCache.clear(); - privateKeyCache.clear(); - profileCache.clear(); - pendingProfiles.clear(); - pendingSecrets.clear(); -} - -/** Exported for testing only. */ -export const PROFILE_TTL_MS = PROFILE_CACHE_TTL_MS; From 30abb17515cb954f668fb32825d06db2919ec567 Mon Sep 17 00:00:00 2001 From: TurtleWolfe Date: Sun, 2 Aug 2026 10:16:42 -0400 Subject: [PATCH 2/4] refactor(messaging): remove the onKeysChanged listener API (no subscribers) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit keyManagementService.onKeysChanged() existed to let the shared-secret cache in useConversationRealtime invalidate on key rotation. That cache is gone, and the API had zero production subscribers even before it went — its own docstring named the dead consumer. Removes the type, the listener Set, onKeysChanged, notifyKeyChange, and its five call sites, plus the one test that exercised it. Also drops two comments in key-service.test.ts asserting a listener "in decrypt-message.ts" that never existed in that file — one of the docstrings that made #69's false root cause look well-sourced. Kept separate from the island deletion so it can be reverted independently: this touches a live service, whereas the island deletion touched nothing that runs. Note a listener is the wrong mechanism for cache invalidation here anyway — static-static ECDH means K(A,B) changes when *either* side rotates, and nothing subscribes to peer key changes. Key-epoch memoization with evict-and-retry is the shape that self-heals; see #92. Refs #92 Co-Authored-By: Claude Opus 5 (1M context) --- .../messaging/__tests__/key-service.test.ts | 11 ----- src/services/messaging/key-service.ts | 40 ------------------- 2 files changed, 51 deletions(-) diff --git a/src/services/messaging/__tests__/key-service.test.ts b/src/services/messaging/__tests__/key-service.test.ts index a35013c5..9d527ea9 100644 --- a/src/services/messaging/__tests__/key-service.test.ts +++ b/src/services/messaging/__tests__/key-service.test.ts @@ -31,7 +31,6 @@ import { KeyMismatchError, ConnectionError, } from '@/types/messaging'; -// decryption-cache clearing is handled via onKeysChanged listener in decrypt-message.ts // Use vi.hoisted to create mock that can be referenced in vi.mock (vitest 4.0 pattern) const { mockKeyDerivationInstance } = vi.hoisted(() => ({ @@ -46,7 +45,6 @@ const { mockKeyDerivationInstance } = vi.hoisted(() => ({ vi.mock('@/lib/supabase/client'); vi.mock('@/lib/supabase/messaging-client'); vi.mock('@/lib/messaging/encryption'); -// decryption-cache no longer imported by key-service (uses onKeysChanged pattern) vi.mock('@/lib/messaging/key-derivation', () => ({ KeyDerivationService: class MockKeyDerivationService { generateSalt = mockKeyDerivationInstance.generateSalt; @@ -470,15 +468,6 @@ describe('KeyManagementService', () => { keyService.clearKeys(); expect(keyService.getCurrentKeys()).toBeNull(); }); - - it('should notify key-change listeners on clearKeys so caches can be flushed', async () => { - const listener = vi.fn(); - keyService.onKeysChanged(listener); - - keyService.clearKeys(); - - expect(listener).toHaveBeenCalledTimes(1); - }); }); describe('revokeKeys()', () => { diff --git a/src/services/messaging/key-service.ts b/src/services/messaging/key-service.ts index b9979abe..2a4aea35 100644 --- a/src/services/messaging/key-service.ts +++ b/src/services/messaging/key-service.ts @@ -32,9 +32,6 @@ import { const logger = createLogger('messaging:keys'); -/** Listener fired whenever the active key pair changes (derived, rotated, or cleared). */ -export type KeyChangeListener = () => void; - export class KeyManagementService { /** In-memory storage for derived keys (cleared on logout) */ private derivedKeys: DerivedKeyPair | null = null; @@ -47,35 +44,6 @@ export class KeyManagementService { /** Key derivation service (Argon2id) */ private keyDerivationService = new KeyDerivationService(); - /** Listeners notified when derivedKeys changes (set/rotate/clear). - * Downstream caches (e.g., shared-secret cache in useConversationRealtime) - * subscribe so they can invalidate on re-auth or key rotation. */ - private keyChangeListeners = new Set(); - - /** - * Subscribe to key lifecycle changes. Fires on: - * - initializeKeys / deriveKeys (new keys loaded) - * - rotateKeys (keys replaced) - * - clearKeys / revokeKeys (keys cleared) - * - restoreKeysFromSession (keys rehydrated from localStorage) - * - * @returns unsubscribe function - */ - onKeysChanged(listener: KeyChangeListener): () => void { - this.keyChangeListeners.add(listener); - return () => this.keyChangeListeners.delete(listener); - } - - private notifyKeyChange(): void { - this.keyChangeListeners.forEach((l) => { - try { - l(); - } catch (err) { - logger.error('Key-change listener threw', { error: err }); - } - }); - } - /** * Initialize encryption keys for NEW user (first login after registration) * Task: T007 (Feature 032) @@ -157,8 +125,6 @@ export class KeyManagementService { // Step 4: Store in memory + localStorage cache (per-user) this.derivedKeys = keyPair; await this.cacheKeysToStorage(keyPair, user.id); - this.notifyKeyChange(); - logger.info('Keys initialized for user', { userId: user.id }); return keyPair; } catch (error) { @@ -279,8 +245,6 @@ export class KeyManagementService { // Step 4: Store in memory + localStorage cache (per-user) this.derivedKeys = keyPair; await this.cacheKeysToStorage(keyPair, user.id); - this.notifyKeyChange(); - logger.info('Keys derived for user', { userId: user.id }); return keyPair; } catch (error) { @@ -334,7 +298,6 @@ export class KeyManagementService { } catch { // SSR or restricted context — ignore } - this.notifyKeyChange(); logger.debug('Keys cleared from memory'); } @@ -374,7 +337,6 @@ export class KeyManagementService { [] ); this.derivedKeys = { privateKey, publicKey, publicKeyJwk, salt }; - this.notifyKeyChange(); console.log('[key-cache] Keys restored from localStorage'); logger.debug('Keys restored from localStorage cache'); return true; @@ -599,8 +561,6 @@ export class KeyManagementService { // Update in-memory keys (password-derived keys are never persisted to IndexedDB) this.derivedKeys = keyPair; await this.cacheKeysToStorage(keyPair, user.id); - this.notifyKeyChange(); - logger.info('Keys rotated for user', { userId: user.id }); return true; } catch (error) { From d0d1d3deb42a24d81a06d27c45d503717479c8b7 Mon Sep 17 00:00:00 2001 From: TurtleWolfe Date: Sun, 2 Aug 2026 10:18:17 -0400 Subject: [PATCH 3/4] docs(messaging): retarget everything that referenced the dead island MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four places documented or scheduled work against modules that never ran: - SECURITY-ARCHITECTURE.md described the shared-secret cache as a live DoS mitigation, with a "~50ms to ~1ms" figure and a pointer to useConversationRealtime.ts. The mitigation was never in effect. Replaced with what the shipping path actually does: one ECDH derivation per getMessageHistory() batch, amortized across up to 50 messages, with no client-side cache. Records what that gives up (nothing security-relevant; cost is tracked in #91) and what it gains (rotation and revocation self-heal, no long-lived key material in module scope). - real-time-delivery.spec.ts credited the dead sync hook with setting data-messages-subscribed; it is set by messages/page.tsx. - specs/010-group-chats T038 and plan.md scheduled MODIFY against the deleted hook — retargeted at messages/page.tsx. T040's typing-indicator half now names useTypingIndicator, which is the hook that actually runs. No references to the island remain anywhere in the repo. Refs #92 Co-Authored-By: Claude Opus 5 (1M context) --- docs/SECURITY-ARCHITECTURE.md | 40 ++++++++++++++----- specs/010-group-chats/plan.md | 3 +- specs/010-group-chats/research.md | 4 +- specs/010-group-chats/tasks.md | 4 +- .../e2e/messaging/real-time-delivery.spec.ts | 2 +- 5 files changed, 37 insertions(+), 16 deletions(-) diff --git a/docs/SECURITY-ARCHITECTURE.md b/docs/SECURITY-ARCHITECTURE.md index 3166c0df..5e17a05e 100644 --- a/docs/SECURITY-ARCHITECTURE.md +++ b/docs/SECURITY-ARCHITECTURE.md @@ -180,22 +180,40 @@ Auth events are logged to `audit_logs` table: ## Performance Security Trade-offs -### ECDH Caching +### ECDH derivation is amortized per batch, not cached -To prevent denial-of-service via expensive cryptographic operations, shared secrets are cached: +There is **no client-side shared-secret cache**. A cached design existed in +`useConversationRealtime` / `decryption-cache`, but it was never wired to any +route and was deleted (#92) — this section previously described it as a live +DoS mitigation, which it never was. -```typescript -// Module-level cache (cleared on page unload) -const sharedSecretCache = new Map(); -``` +The shipping path derives the shared secret **once per `getMessageHistory()` +call** and reuses it across every message in that page: + +- `message-service.ts:801` — one `deriveSharedSecret()` per batch +- `message-service.ts:813-876` — up to 50 AES-GCM decryptions against it + +So the expensive asymmetric operation is already amortized over the page. Cost +scales with the number of _fetches_, not the number of messages. + +**Security properties this gives up, and gains:** -- **Key**: `${conversationId}:${otherParticipantId}` -- **Value**: Derived CryptoKey -- **Invalidation**: Page unload, logout +- **Gives up**: nothing meaningful. Re-derivation is a cost issue, not a + security one, and it is tracked as such in #91. +- **Gains**: no long-lived key material in module scope, and rotation and + revocation self-heal — `getUserPublicKey()` re-applies `.eq('revoked', false)` + (`key-service.ts:691`) on every fetch, so a revoked peer key stops being used + within one cycle with no invalidation machinery to get wrong. -This reduces per-message decryption from ~50ms to ~1ms. +Derived message keys are created **non-extractable** (`encryption.ts:97`), so +they cannot be exported to raw bytes by page script. -**File**: `src/hooks/useConversationRealtime.ts` +If per-message memoization is ever reintroduced, key it on +`(messageId, keyEpoch)` where the epoch derives from **both** parties' current +public keys, with evict-and-retry-once on decrypt failure. A subscription-based +invalidation (the previous design) is structurally blind to _peer_ rotation: +static-static ECDH means K(A,B) changes when either side rotates, and nothing +subscribes to the other party's key changes. ## OWASP Top 10 Compliance diff --git a/specs/010-group-chats/plan.md b/specs/010-group-chats/plan.md index 1e9923ea..15e67498 100644 --- a/specs/010-group-chats/plan.md +++ b/specs/010-group-chats/plan.md @@ -77,7 +77,8 @@ src/ │ └── connection-service.ts # MODIFY: Group creation ├── hooks/ │ ├── useGroupMembers.ts # NEW: Group member management -│ └── useConversationRealtime.ts # MODIFY: Group message handling +│ # (no hook here — the realtime subscription +│ # lives in src/app/messages/page.tsx) ├── types/ │ └── messaging.ts # MODIFY: Add group types ├── lib/ diff --git a/specs/010-group-chats/research.md b/specs/010-group-chats/research.md index 495d7cc1..ab21dab8 100644 --- a/specs/010-group-chats/research.md +++ b/specs/010-group-chats/research.md @@ -154,7 +154,9 @@ All technical context items resolved. No NEEDS CLARIFICATION items identified - - Subscribe to `conversation_members` changes for member join/leave - Subscribe to `messages` for new group messages -- Pattern matches existing `useConversationRealtime` hook +- Pattern matches the realtime subscription in `src/app/messages/page.tsx` + (the former `useConversationRealtime` hook was never wired to a route and was + deleted in #92 — do not build against it) ### RLS Policies for Group Security diff --git a/specs/010-group-chats/tasks.md b/specs/010-group-chats/tasks.md index 9651fa46..56739589 100644 --- a/specs/010-group-chats/tasks.md +++ b/specs/010-group-chats/tasks.md @@ -103,9 +103,9 @@ - [ ] T035 [US2] Implement group message encryption: fetch group key, encrypt with AES-GCM, store with key_version (depends on T034) - [ ] T036 [US2] Modify getMessageHistory() in same file to decrypt group messages using appropriate key version (depends on T035) - [ ] T037 [US2] Implement key_version_joined check: show "[Message before you joined]" placeholder text per SC-003 for pre-join messages (depends on T036) -- [ ] T038 [US2] Update useConversationRealtime hook in `src/hooks/useConversationRealtime.ts` to handle group message decryption +- [ ] T038 [US2] Update the realtime subscription in `src/app/messages/page.tsx` to handle group message decryption (retargeted in #92: the `useConversationRealtime` hook this originally named was never wired to a route and has been deleted) - [ ] T039 [US2] Modify MessageBubble in `src/components/atomic/MessageBubble/MessageBubble.tsx` to show sender avatar for group messages -- [ ] T040 [US2] Modify ChatWindow in `src/components/organisms/ChatWindow/ChatWindow.tsx` to detect group and show multiple typing indicators (up to 3 names, then "X others are typing" per FR-015); update useConversationRealtime to track multiple typers +- [ ] T040 [US2] Modify ChatWindow in `src/components/organisms/ChatWindow/ChatWindow.tsx` to detect group and show multiple typing indicators (up to 3 names, then "X others are typing" per FR-015); update `useTypingIndicator` to track multiple typers **Checkpoint**: User Story 2 complete - encrypted group messaging works, history restriction verified diff --git a/tests/e2e/messaging/real-time-delivery.spec.ts b/tests/e2e/messaging/real-time-delivery.spec.ts index bf2bca40..95d7de4c 100644 --- a/tests/e2e/messaging/real-time-delivery.spec.ts +++ b/tests/e2e/messaging/real-time-delivery.spec.ts @@ -166,7 +166,7 @@ async function navigateBothToConversation( } // Best-effort wait for Realtime subscription readiness. - // useConversationRealtimeSync sets data-messages-subscribed on document.body + // messages/page.tsx sets data-messages-subscribed on document.body // when the channel reaches SUBSCRIBED. Under free-tier contention the channel // may never reach SUBSCRIBED — the 10s polling fallback in the app guarantees // eventual message delivery regardless, so we proceed after a reasonable wait. From 325d114b5248dec23e14eb903d8c06b5e748d282 Mon Sep 17 00:00:00 2001 From: TurtleWolfe Date: Sun, 2 Aug 2026 10:21:24 -0400 Subject: [PATCH 4/4] ci: add knip to detect dead code, with a baseline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TypeScript's unused-symbol checks do not span modules, so a closed island — a group of files importing each other with no entry point from the app — type-checks and builds cleanly forever. Nothing in this repo could detect that shape. One survived from the initial commit to 2026-08 (#92), and the same class produced the 131 files deleted in #82 and the leads in #90. knip.jsonc configures app-router entry points and disables knip's built-in Next plugin, whose assumptions produce false entries against this static export. The gate is baselined: 37 pre-existing findings are listed under `ignore` so CI is green on arrival and fails only on NEW unreferenced files. Two distinct categories are in there and the config says so — one-line barrels re-exporting live components (an import-style choice, explicitly out of scope per #82) and genuine orphans awaiting individual verification under #90. It is a burn-down list, not an allowlist. .storybook/mocks/** is ignored for a different reason: those files are reached through path.resolve aliases in .storybook/main.ts:43,47, which knip cannot follow. Reachable, not orphaned. Mutation-checked: planting a new unreferenced file under src/lib/ turns the gate red and names the file; removing it returns exit 0. Refs #92, #90, #82 Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yml | 12 + knip.jsonc | 86 +++++ package.json | 2 + pnpm-lock.yaml | 733 ++++++++++++++++++++++++++++++++++----- 4 files changed, 738 insertions(+), 95 deletions(-) create mode 100644 knip.jsonc diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2b2841b1..f5d3274a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -105,6 +105,18 @@ jobs: - name: Type check run: pnpm type-check + # TypeScript's unused-symbol checks do not span modules, so a *closed + # island* — a group of files importing each other with no entry point + # from the app — compiles cleanly and type-checks cleanly forever. One + # (4 messaging modules, 719 LOC) survived from the initial commit to + # 2026-08 and cost two debugging sessions plus a false root cause on #69 + # before anyone noticed. This is the only check that catches that shape. + # + # knip.jsonc carries a baseline of pre-existing findings (see #90) so + # this gate is green on arrival and fails only on NEW dead files. + - name: Detect dead code (unreferenced files) + run: pnpm lint:dead + # scripts/validate-breakpoints.ts asserts that the --breakpoint-* custom # properties in globals.css match BREAKPOINTS in src/config/breakpoints.ts, # and that the ranges have no gaps. It has existed since the breakpoints diff --git a/knip.jsonc b/knip.jsonc new file mode 100644 index 00000000..543bdea6 --- /dev/null +++ b/knip.jsonc @@ -0,0 +1,86 @@ +{ + "$schema": "https://unpkg.com/knip@5/schema.json", + + // Next.js app-router entry points. knip's built-in Next plugin is disabled + // (`"next": { "entry": [] }`) because this project is a static export with a + // non-default layout, and the plugin's assumptions produced false entries. + "entry": [ + "src/app/**/{page,layout,template,loading,error,not-found,global-error,route,default}.{ts,tsx}", + ".storybook/{main,preview}.{ts,tsx}", + "scripts/**/*.{ts,mjs,js}", + "tests/**/*.{ts,tsx}", + "src/**/*.stories.tsx", + "src/**/*.test.{ts,tsx}", + "src/**/*.accessibility.test.tsx", + "public/sw.js" + ], + + "project": ["src/**/*.{ts,tsx}"], + + "ignoreDependencies": ["sharp", "canvas", "argon2"], + "ignoreBinaries": ["docker", "supabase"], + + // ── Baseline (#92) ──────────────────────────────────────────────────────── + // + // knip was added to catch a *closed dead island* — four messaging modules + // that imported each other and nothing else, which survived eight months + // because TypeScript's unused-symbol checks do not span modules. Nothing in + // the repo could detect that mechanically. + // + // These entries are the pre-existing findings at the time knip landed. They + // are ignored so the gate is green on day one and fails on anything NEW. + // This is a burn-down list, not an allowlist — see #90, which is the audit + // of these same orphans. Delete entries from here as they are resolved. + // + // Two distinct categories, deliberately not separated in config because knip + // takes one list: + // 1. One-line barrel files (`index.ts`) re-exporting a LIVE component. + // An import-style decision, not dead code — see #82's out-of-scope note. + // 2. Genuine orphans awaiting individual verification under #90. + "ignore": [ + // Resolved by Vite alias in .storybook/main.ts:43,47 (path.resolve), which + // knip cannot follow — these are reachable, not orphaned. + ".storybook/mocks/**", + + "src/components/AccessibilityProvider.tsx", + "src/components/atomic/Card/index.tsx", + "src/components/atomic/index.ts", + "src/components/atomic/QueueStatusIndicator/useQueueStatusIndicator.ts", + "src/components/atomic/ReadReceipt/index.tsx", + "src/components/forms/FormError.tsx", + "src/components/forms/FormField.tsx", + "src/components/forms/index.ts", + "src/components/forms/ValidatedInput.tsx", + "src/components/molecular/BlogContent/index.tsx", + "src/components/molecular/MessageThread/useMessageThread.ts", + "src/components/organisms/ChatWindow/useChatWindow.ts", + "src/components/organisms/CompanyMap/index.tsx", + "src/components/organisms/ConnectionManager/useConnectionManager.ts", + "src/components/payment/PaymentButton/index.tsx", + "src/components/payment/PaymentConsentModal/index.tsx", + "src/components/payment/PaymentHistory/index.tsx", + "src/components/payment/PaymentStatusDisplay/index.tsx", + "src/components/subatomic/index.ts", + "src/components/subatomic/Text/index.tsx", + "src/config/blog.config.ts", + "src/config/social-platforms.ts", + "src/config/social.ts", + "src/lib/analytics/index.tsx", + "src/lib/auth/protected-route.tsx", + "src/lib/companies/index.ts", + "src/lib/map/index.ts", + "src/lib/seo/content.ts", + "src/lib/seo/keywords.ts", + "src/lib/seo/readability.ts", + "src/lib/seo/technical.ts", + "src/lib/supabase/messaging-types.ts", + "src/lib/supabase/server.ts", + "src/lib/validation/index.ts", + "src/types/**", + "src/utils/codeblock-utils.ts", + "src/utils/map-colors.ts", + "src/utils/test-utils.ts" + ], + + "next": { "entry": [] } +} diff --git a/package.json b/package.json index 8eba9c8a..92862252 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "lint:staged": "lint-staged", "clean:next": "rm -rf .next/* .next/.* 2>/dev/null || true", "type-check": "tsc --noEmit", + "lint:dead": "knip --include files --no-progress", "ci:validate": "./scripts/validate-ci.sh", "validate:breakpoints": "tsx scripts/validate-breakpoints.ts", "ci:quick": "./scripts/validate-ci.sh --quick", @@ -173,6 +174,7 @@ "husky": "^9.1.7", "jest-axe": "^10.0.0", "jsdom": "^26.1.0", + "knip": "^6.31.0", "libsodium-wrappers": "^0.8.1", "lint-staged": "^16.1.6", "msw": "^2.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3626f72f..4a5c6bc4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -201,7 +201,7 @@ importers: version: 10.2.8(storybook@10.2.13(@testing-library/dom@10.4.0)(prettier@3.6.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)) '@storybook/nextjs-vite': specifier: 10.2.8 - version: 10.2.8(@babel/core@7.28.4)(esbuild@0.25.9)(next@15.5.10(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(rollup@4.59.0)(storybook@10.2.13(@testing-library/dom@10.4.0)(prettier@3.6.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(typescript@5.9.2)(vite@7.2.7(@types/node@20.19.14)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)) + version: 10.2.8(@babel/core@7.28.4)(esbuild@0.25.9)(next@15.5.10(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(rollup@4.59.0)(storybook@10.2.13(@testing-library/dom@10.4.0)(prettier@3.6.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(typescript@5.9.2)(vite@7.2.7(@types/node@20.19.14)(jiti@2.7.0)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0)) '@tailwindcss/postcss': specifier: ^4.1.13 version: 4.1.13 @@ -243,7 +243,7 @@ importers: version: 19.1.9(@types/react@19.1.13) '@vitejs/plugin-react': specifier: ^5.0.2 - version: 5.0.2(vite@7.2.7(@types/node@20.19.14)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)) + version: 5.0.2(vite@7.2.7(@types/node@20.19.14)(jiti@2.7.0)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0)) '@vitest/coverage-v8': specifier: ^4.0.15 version: 4.0.15(vitest@4.0.15) @@ -273,16 +273,16 @@ importers: version: 17.2.2 eslint: specifier: ^9.35.0 - version: 9.35.0(jiti@2.5.1) + version: 9.35.0(jiti@2.7.0) eslint-config-next: specifier: 15.5.2 - version: 15.5.2(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.2) + version: 15.5.2(eslint@9.35.0(jiti@2.7.0))(typescript@5.9.2) eslint-plugin-no-secrets: specifier: ^2.2.1 - version: 2.2.1(eslint@9.35.0(jiti@2.5.1)) + version: 2.2.1(eslint@9.35.0(jiti@2.7.0)) eslint-plugin-storybook: specifier: ^10.2.8 - version: 10.2.8(eslint@9.35.0(jiti@2.5.1))(storybook@10.2.13(@testing-library/dom@10.4.0)(prettier@3.6.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(typescript@5.9.2) + version: 10.2.8(eslint@9.35.0(jiti@2.7.0))(storybook@10.2.13(@testing-library/dom@10.4.0)(prettier@3.6.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(typescript@5.9.2) fake-indexeddb: specifier: ^6.2.2 version: 6.2.2 @@ -304,6 +304,9 @@ importers: jsdom: specifier: ^26.1.0 version: 26.1.0(canvas@3.2.0) + knip: + specifier: ^6.31.0 + version: 6.31.0 libsodium-wrappers: specifier: ^0.8.1 version: 0.8.1 @@ -360,10 +363,10 @@ importers: version: 5.9.2 vite: specifier: '>=7.1.11' - version: 7.2.7(@types/node@20.19.14)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1) + version: 7.2.7(@types/node@20.19.14)(jiti@2.7.0)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0) vitest: specifier: ^4.0.15 - version: 4.0.15(@opentelemetry/api@1.9.0)(@types/node@20.19.14)(@vitest/ui@4.0.15)(happy-dom@20.8.9)(jiti@2.5.1)(jsdom@26.1.0(canvas@3.2.0))(lightningcss@1.30.1)(msw@2.7.0(@types/node@20.19.14)(typescript@5.9.2))(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1) + version: 4.0.15(@opentelemetry/api@1.9.0)(@types/node@20.19.14)(@vitest/ui@4.0.15)(happy-dom@20.8.9)(jiti@2.7.0)(jsdom@26.1.0(canvas@3.2.0))(lightningcss@1.30.1)(msw@2.7.0(@types/node@20.19.14)(typescript@5.9.2))(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0) wait-on: specifier: ^8.0.5 version: 8.0.5 @@ -555,15 +558,24 @@ packages: resolution: {integrity: sha512-DGSlP9sPvyFba3to2A50kDtZ+pXVp/0rhmqs2LmbMS3I5J8FSOgLwzY2Xb4qfKlOVHh29EAutLYwe5yuEZmEFg==} engines: {node: '>=14.0.0'} + '@emnapi/core@1.11.2': + resolution: {integrity: sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==} + '@emnapi/core@1.5.0': resolution: {integrity: sha512-sbP8GzB1WDzacS8fgNPpHlp6C9VZe+SJP3F90W9rLemaQj2PzIuTEl1qDOYQf58YIpyjViI24y9aPWCjEzY2cg==} + '@emnapi/runtime@1.11.2': + resolution: {integrity: sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==} + '@emnapi/runtime@1.5.0': resolution: {integrity: sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ==} '@emnapi/wasi-threads@1.1.0': resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==} + '@emnapi/wasi-threads@1.2.2': + resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} + '@epic-web/invariant@1.0.0': resolution: {integrity: sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==} @@ -1080,6 +1092,13 @@ packages: '@napi-rs/wasm-runtime@0.2.12': resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} + '@napi-rs/wasm-runtime@1.2.2': + resolution: {integrity: sha512-JfB4kuJQjaoHuCTseIINHtHWeJnvgEcxjwA5t/Y00ZgaOO1Crz3fjT/p8kT28zA/Caz7oiUMn3d6H2yOVCVwuw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} + peerDependencies: + '@emnapi/core': ^1.7.1 || ^2.0.0-alpha.3 + '@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.3 + '@next/bundle-analyzer@15.5.3': resolution: {integrity: sha512-l2NxnWHP2gWHbomAlz/wFnN2jNCx/dpr7P/XWeOLhULiyKkXSac8O8SjxRO/8FNhr2l4JNtWVKk82Uya4cZYTw==} @@ -1177,6 +1196,223 @@ packages: resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==} engines: {node: '>=8.0.0'} + '@oxc-parser/binding-android-arm-eabi@0.142.0': + resolution: {integrity: sha512-ZiRGDutGsv1G6bL/ozy/koC0Sv39T1DqyoC4KD1DOy9ZoACm1O5UWhEK2c02Qdk+4lfLVkvFa/mQ0fm/4h1BtQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxc-parser/binding-android-arm64@0.142.0': + resolution: {integrity: sha512-WZkvGRLNQTz8lR9zP5nLjUdlroRCopBu3g9zF1p/laE6DzT1UbQo8Rdz5MWhaJUPYg/6gp+jo7HUgsyKaN1FtQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxc-parser/binding-darwin-arm64@0.142.0': + resolution: {integrity: sha512-l4khS8LQOOVYsGRVARo1gSaCT/aBSceUVXgtovWc2+drnxVuDr082WA3OCHVdVzIz5JIrP/y9CWsSKxBDNmYGg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxc-parser/binding-darwin-x64@0.142.0': + resolution: {integrity: sha512-QBsNF3nqlXmcH2B1YOPqQYmCJoy4HuIjUxGbBO/k5JAJUl68ghU2psRY2zPk+RyBaWqKP/qfL4oaFgEMCdwskA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxc-parser/binding-freebsd-x64@0.142.0': + resolution: {integrity: sha512-b7Q7m4Cqc6XqNhri3R+QhU+GVy646Pn+bkdhrDdWym/Fdi0ZUa+d73H9dm5H91JtbtAQ/z1d8XKMW3oOV8a4tQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxc-parser/binding-linux-arm-gnueabihf@0.142.0': + resolution: {integrity: sha512-3riVS5IhdH3uCZj1Y9ftDQlR0dvLsIlw/edrRqk8JhgNd5K0XSs+UBtgh50N13CAlW9/TXj6sVGXaKNBocd0Yg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxc-parser/binding-linux-arm-musleabihf@0.142.0': + resolution: {integrity: sha512-NmXUOpgpTSkhl795TiXmWppTwmSJ92RC1qvD6e4XOF+slgmo3e6Ah+kEu+6AN8s7NAOEwqGmir58MgSQSWmBSA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxc-parser/binding-linux-arm64-gnu@0.142.0': + resolution: {integrity: sha512-gc0EXsKtXgerujmU2Bql3u1L1HsSQ2774R83idq/FoNMPVV/RY/1ErFsvnit7KoiP/sLvzQixeUo4Ut0ic0wmw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@oxc-parser/binding-linux-arm64-musl@0.142.0': + resolution: {integrity: sha512-F2XvmWSE0uWpie+jHKKIFgdVOe9ypGhkEZxKx5DuW215K6cbAC274yYaPkcM7EqY4Df3Weyhpcz3lsURyH2LVg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@oxc-parser/binding-linux-ppc64-gnu@0.142.0': + resolution: {integrity: sha512-wLMbT21U/QxknQsk+VvNF0b9D2/aGWhcaQQQ+VYlE8FwD5+GoWZIPPXNzyHmkYyhm0KB3itL+TBavjMatqNnYA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + + '@oxc-parser/binding-linux-riscv64-gnu@0.142.0': + resolution: {integrity: sha512-+G8F/4ckwT7FCJV4H2bt09xEzJbjNCfuL4Sp1AYNaFtFMVtgIGMuJlteT82U+K0UIZ/DzAR/LDlMFnEuajG7Kw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + + '@oxc-parser/binding-linux-riscv64-musl@0.142.0': + resolution: {integrity: sha512-hTsHtTLxMAfCo+rpF5K3qZJKW2NpPN/CHd4mYB3y7XlSdspHkd2gehDIofP64AacA9nWQw2tY3O7wR6UY8IVOA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + + '@oxc-parser/binding-linux-s390x-gnu@0.142.0': + resolution: {integrity: sha512-6y7qYY3TCUDYjqswImdTGl92y+KA/80twALegQPN27kfY+bG7Ib1+L3jbmrCZQx6wrVnai9IPsEZp07I0hx7JQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + + '@oxc-parser/binding-linux-x64-gnu@0.142.0': + resolution: {integrity: sha512-i69kAWU+2LgoH5bR+zWiiu+UzAw7Oxkwv7COeJTeY19pn4e70nKQcr9Pm6cL2Z0Z54d+gl9qADlK/0yyuCPiBA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@oxc-parser/binding-linux-x64-musl@0.142.0': + resolution: {integrity: sha512-4SQs678MmjYVrmhAgCWD4o0vpaFszXw9xLX5p2Z9MMFcltxiLkA88wQjh80YHjPrXtpyZ2CWI5m+1yNKM0m2Pw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@oxc-parser/binding-openharmony-arm64@0.142.0': + resolution: {integrity: sha512-YHpx9N7Ln3a++Tc8rv+H7mrK1zyJQOAwCFg8LZ3lTs1T5afGWeZrLPhPT9HLnIwSjCyJqPWVMIrMxbjcmBr2oQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxc-parser/binding-wasm32-wasi@0.142.0': + resolution: {integrity: sha512-3pLDyY3+oogW73RM5uehNgAiR/Xfb7fvO2Q1Z1gIqZ2+50XDVQmBVlRkHXZTU4gKnQHpwETNsYQVsJ3joVB2iA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [wasm32] + + '@oxc-parser/binding-win32-arm64-msvc@0.142.0': + resolution: {integrity: sha512-Had/VeVY28Oyb0K+Q4FV8KCzoBycIh93oDK6pCbya9lkzdq+ikMHMgBubsdqqlybjJmQRawCQRrnBRHyQwYvcQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxc-parser/binding-win32-ia32-msvc@0.142.0': + resolution: {integrity: sha512-GGi3+YphVHavvgs6gum2UXoNCqzHAmPt/nXkn8ZQZstV2Q1qZD1Mn8fz/nWrDkefHQtrG/+1/XrbMxsBTo6Svw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxc-parser/binding-win32-x64-msvc@0.142.0': + resolution: {integrity: sha512-Ny/Wv4Us1LGC/ljwNTp+Hx3r/pH15EFfeDF0p+n898gt+TtRd6C9SccHcuUhDiNTb8s5tt7jdeAMDRQZ4Vq6hg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@oxc-project/types@0.142.0': + resolution: {integrity: sha512-7W+2q5AKQVU36fkaryontrHn3YDt1RyUYXatw9i5H8ocYe2sPKSFB6eS8WNPeRKiN1qAWWZUPm7gwFzJGrccqQ==} + + '@oxc-resolver/binding-android-arm-eabi@11.24.2': + resolution: {integrity: sha512-y09e0L0SRI2OA2tUIrjBgoV3eH5hvUKXNkJqXmNo5V2WxIjyC7I7aJfRLMEVpA8yi95f90gFDvO0VMgrDw+vwA==} + cpu: [arm] + os: [android] + + '@oxc-resolver/binding-android-arm64@11.24.2': + resolution: {integrity: sha512-cl4icWaZFnLdg8m6qtnh5rBMuGbxc/ptStFHLeCNwr+2cZjkjNwQu/jYRS0CHlnPecOJMpuS5M6/BH+0J/YkEg==} + cpu: [arm64] + os: [android] + + '@oxc-resolver/binding-darwin-arm64@11.24.2': + resolution: {integrity: sha512-At29QEMF6HajbQvgY8K6OXnHD1x9rad74xBEfmCB6ZqCGsdq75aK7tOYcTbOanMy8qdIBrfL3SMr3p/lfSlb9w==} + cpu: [arm64] + os: [darwin] + + '@oxc-resolver/binding-darwin-x64@11.24.2': + resolution: {integrity: sha512-A5Kqr1EUj4oIL5CF4WRssq/o5P0Y11cwoFouMRmQ7YnC/A8V93nv1nb7aSU8HwcgmXropjLNkVTl4MN87cu28Q==} + cpu: [x64] + os: [darwin] + + '@oxc-resolver/binding-freebsd-x64@11.24.2': + resolution: {integrity: sha512-R5xkRBRRz7ceH/P5Jrc6G7FmdUdgpLYyESFAUDVTNQ9K0sGPxcp4ljiwEwEqsvNcQ4sYbMRrWcHHBCu7ksAJVw==} + cpu: [x64] + os: [freebsd] + + '@oxc-resolver/binding-linux-arm-gnueabihf@11.24.2': + resolution: {integrity: sha512-k/RuYL4L/R58IBn3wT5ma3Wh4k62bp1eYCFRWCmMsasUOqL+H6sW0VGFadEzKWXFFlz+2uIMoeMk9ySSZJHgbg==} + cpu: [arm] + os: [linux] + + '@oxc-resolver/binding-linux-arm-musleabihf@11.24.2': + resolution: {integrity: sha512-bnHAak3ujYfH5pKk4NieFNbvYvernfoQDgwLddbZ3OtMYrem87/qjlA+u+aKG0oZcqSLGCful/6/CEA+aeAgaA==} + cpu: [arm] + os: [linux] + + '@oxc-resolver/binding-linux-arm64-gnu@11.24.2': + resolution: {integrity: sha512-vDT3KHgzYp47gmtNOqL2VNhCyl5Zv643eyxm//A68J8DeUGXrvD1pZFiaT4jSfe+RInfnn1R2yVHye4enx6RnA==} + cpu: [arm64] + os: [linux] + + '@oxc-resolver/binding-linux-arm64-musl@11.24.2': + resolution: {integrity: sha512-+kMlQvbzfyEYtu5FcjE4p+ttBLpKW4d/AsAsuE69BxV6V4twZJeIQZFfD8gh/wqglY0MkPSezWXQH0jBV13MUw==} + cpu: [arm64] + os: [linux] + + '@oxc-resolver/binding-linux-ppc64-gnu@11.24.2': + resolution: {integrity: sha512-shjfMhmZ3gq9fv/w7bi3PnZlgOPG+2QAOFf0BJF0EgBSIGZ6PMLN2zbGEblTUYB/NKVDRyYhE2ff3dJ1QqNPkA==} + cpu: [ppc64] + os: [linux] + + '@oxc-resolver/binding-linux-riscv64-gnu@11.24.2': + resolution: {integrity: sha512-zGelwFR5oRo+b69k8Lrzun86DyUHzfKN6cnjbR9l7Z7NIRznOE/2ZvPa1IUKqAL2PzAXOdwkfVqNvO1H2RlpAw==} + cpu: [riscv64] + os: [linux] + + '@oxc-resolver/binding-linux-riscv64-musl@11.24.2': + resolution: {integrity: sha512-qxZ1SWCXJY0eyhAlP6Lmo9F2Nrtx7EkYj9oCgL8apDPCwXwCEDA2U697bbT81JIc2IrVjxO4KX6WU2N+oN9Z4w==} + cpu: [riscv64] + os: [linux] + + '@oxc-resolver/binding-linux-s390x-gnu@11.24.2': + resolution: {integrity: sha512-sGCecF3cx2DFlH4t/z7ApnOnXqN48p5p5mlHDEnHTAukQa2P+qMVE4CwyWE9W+q/m3QJ7kKfGrIjax31f44oFQ==} + cpu: [s390x] + os: [linux] + + '@oxc-resolver/binding-linux-x64-gnu@11.24.2': + resolution: {integrity: sha512-k/VlMMcSzMlahb3/fENM4rTlsJ0s3fFROA0KXPBmKggqmTSaE383sl8F3KCOXPLmVsYfW6hCitMhXCEtNeZxxg==} + cpu: [x64] + os: [linux] + + '@oxc-resolver/binding-linux-x64-musl@11.24.2': + resolution: {integrity: sha512-8hbnZyNi97b/8wapYaIF9+t9GmZKBW2vunaOc3h9HGJptH7b7XpvZqOTBSm/MpTjr7H497BlgOaSfLUdhmy2bw==} + cpu: [x64] + os: [linux] + + '@oxc-resolver/binding-openharmony-arm64@11.24.2': + resolution: {integrity: sha512-MvyGik3a6pVgZ0t/kWlbmFxFLmXQJwgLsY2eYFHLpy0wGwRbfzeIGgDwQ3kXqE30z+kSXennRkCrT7TUvkptNg==} + cpu: [arm64] + os: [openharmony] + + '@oxc-resolver/binding-wasm32-wasi@11.24.2': + resolution: {integrity: sha512-vHcssMPwO08RTvj/c0iOBz90attxyG3wQJ0dTcyEQK43LRpcdLWZlV5feBhv6Isn6ahbQIzHbCgfa81+RiML0Q==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@oxc-resolver/binding-win32-arm64-msvc@11.24.2': + resolution: {integrity: sha512-uokJqro2iBqkFvJdKQLP7d8/BUmFwESQFVmIJUQKj1Xn1a/LysJoe1vmeECLF5b3jsV8CAL5sEMJXX6SdK9Nhg==} + cpu: [arm64] + os: [win32] + + '@oxc-resolver/binding-win32-x64-msvc@11.24.2': + resolution: {integrity: sha512-UqGPmo56KDfLlfXFAFIrNflHT8tFxWGEivWg3Zeyp4Uy2NlKN1FGPr6/BxcLGG3+kZ6Wp14g5Uj+n71boqZfiw==} + cpu: [x64] + os: [win32] + '@phc/format@1.0.0': resolution: {integrity: sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ==} engines: {node: '>=10'} @@ -1609,6 +1845,9 @@ packages: '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} + '@tybys/wasm-util@0.10.3': + resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} + '@types/aria-query@5.0.4': resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} @@ -3042,6 +3281,9 @@ packages: fault@2.0.1: resolution: {integrity: sha512-WtySTkS4OKev5JtpHXnib4Gxiurzh5NCGvWrFaZ34m6JehfTUhKZvn9njTfw48t6JumVQOmrKqpmGcdwxnhqBQ==} + fd-package-json@2.0.0: + resolution: {integrity: sha512-jKmm9YtsNXN789RS/0mSzOC1NUq9mkVd65vbSSVsKdjGvYXBuE4oWe2QOEoFeRmJg+lPuZxpmrfFclNhoRMneQ==} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -3117,6 +3359,11 @@ packages: resolution: {integrity: sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==} engines: {node: '>=0.4.x'} + formatly@0.3.0: + resolution: {integrity: sha512-9XNj/o4wrRFyhSMJOvsuyMwy8aUfBaZ1VrqHVfohyXf0Sw0e+yfKG+xZaY3arGCOMdwFsqObtzVOc1gU9KiT9w==} + engines: {node: '>=18.3.0'} + hasBin: true + from@0.1.7: resolution: {integrity: sha512-twe20eF1OxVxp/ML/kq2p1uc6KvFK/+vs8WjEbeKmV2He22MKm7YF2ANIt+EOqhJ5L3K/SuuPhk0hWQDjOM23g==} @@ -3174,6 +3421,9 @@ packages: get-tsconfig@4.10.1: resolution: {integrity: sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ==} + get-tsconfig@4.14.0: + resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==} + get-value@2.0.6: resolution: {integrity: sha512-Ln0UQDlxH1BapMu3GPtf7CuYNwRZf2gwCuPqbyG6pB8WfmFpzqcy4xtAaAMUhnNqjMKTiCPZG2oMT3YSx8U2NA==} engines: {node: '>=0.10.0'} @@ -3725,6 +3975,10 @@ packages: resolution: {integrity: sha512-twQoecYPiVA5K/h6SxtORw/Bs3ar+mLUtoPSc7iMXzQzK8d7eJ/R09wmTwAjiamETn1cXYPGfNnu7DMoHgu12w==} hasBin: true + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} + hasBin: true + joi@17.13.3: resolution: {integrity: sha512-otDA4ldcIx+ZXsKHWmp0YizCweVRZG96J10b0FevjfuncLO1oX59THoAmHkNubYJ+9gWsYsp5k8v4ib6oDv1fA==} @@ -3808,6 +4062,11 @@ packages: resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==} engines: {node: '>=0.10.0'} + knip@6.31.0: + resolution: {integrity: sha512-NbeIEmUS2VUMjAkbiSNOKPJeV9wpCsr0660sUyKyMQbk4Iom0++nTLInVp4MJ+LfR4kORnw67bDi5tvO7YLnzA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + language-subtag-registry@0.3.23: resolution: {integrity: sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==} @@ -4434,6 +4693,13 @@ packages: resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} engines: {node: '>= 0.4'} + oxc-parser@0.142.0: + resolution: {integrity: sha512-kKR+jPiRJYJDexVoziIg/FVGvr1fT1FZSSJOk6tVoMKKSlsf1Cso+cgGCJkOEDWOP174vRntCPFKg+AS7InWvw==} + engines: {node: ^20.19.0 || >=22.12.0} + + oxc-resolver@11.24.2: + resolution: {integrity: sha512-FY91FiDBj7ls5MsFS9jN3tjz2o0/zsdSsymlakySaBwVJZorHhkWyICLZMKxlu1R9vYo+sd3z1jwb4J8x7bNDw==} + p-limit@3.1.0: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} @@ -5133,6 +5399,10 @@ packages: resolution: {integrity: sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==} engines: {node: '>=18'} + smol-toml@1.7.1: + resolution: {integrity: sha512-PPlsspAZ4jbMBu5DMFhfUGDQLu/vrL4SyBROVS37x8ynnVmFIs1VPBz1Co8Xks3TvpIaZXmU85y4DrQ+UyVFoQ==} + engines: {node: '>= 18'} + sort-asc@0.2.0: resolution: {integrity: sha512-umMGhjPeHAI6YjABoSTrFp2zaBtXBej1a0yKkuMUyjjqu6FJsTF+JYwCswWDg+zJfk/5npWUUbd33HH/WLzpaA==} engines: {node: '>=0.10.0'} @@ -5305,6 +5575,10 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} + strip-json-comments@5.0.3: + resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==} + engines: {node: '>=14.16'} + styled-jsx@5.1.6: resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==} engines: {node: '>= 12.0.0'} @@ -5382,6 +5656,10 @@ packages: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + tinyqueue@3.0.0: resolution: {integrity: sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==} @@ -5549,6 +5827,10 @@ packages: engines: {node: '>=0.8.0'} hasBin: true + unbash@4.0.4: + resolution: {integrity: sha512-60m9IVGbavD6jholbxt0jVBXZkEB/HsMZq7Tyaghseve2/Sf0zQRAIfWsD34sde+DKP2tBxJS2wP88ZM0D1FhA==} + engines: {node: '>=14'} + unbox-primitive@1.1.0: resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} engines: {node: '>= 0.4'} @@ -5746,6 +6028,10 @@ packages: engines: {node: '>=12.0.0'} hasBin: true + walk-up-path@4.0.0: + resolution: {integrity: sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A==} + engines: {node: 20 || >=22} + wcwidth@1.0.1: resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} @@ -5908,6 +6194,11 @@ packages: engines: {node: '>= 14.6'} hasBin: true + yaml@2.9.0: + resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} + engines: {node: '>= 14.6'} + hasBin: true + yargs-parser@21.1.1: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} engines: {node: '>=12'} @@ -5927,6 +6218,9 @@ packages: zod@4.1.8: resolution: {integrity: sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ==} + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} @@ -6139,12 +6433,23 @@ snapshots: '@emailjs/browser@4.4.1': {} + '@emnapi/core@1.11.2': + dependencies: + '@emnapi/wasi-threads': 1.2.2 + tslib: 2.8.1 + optional: true + '@emnapi/core@1.5.0': dependencies: '@emnapi/wasi-threads': 1.1.0 tslib: 2.8.1 optional: true + '@emnapi/runtime@1.11.2': + dependencies: + tslib: 2.8.1 + optional: true + '@emnapi/runtime@1.5.0': dependencies: tslib: 2.8.1 @@ -6155,6 +6460,11 @@ snapshots: tslib: 2.8.1 optional: true + '@emnapi/wasi-threads@1.2.2': + dependencies: + tslib: 2.8.1 + optional: true + '@epic-web/invariant@1.0.0': {} '@esbuild/aix-ppc64@0.25.9': @@ -6235,14 +6545,14 @@ snapshots: '@esbuild/win32-x64@0.25.9': optional: true - '@eslint-community/eslint-utils@4.9.0(eslint@9.35.0(jiti@2.5.1))': + '@eslint-community/eslint-utils@4.9.0(eslint@9.35.0(jiti@2.7.0))': dependencies: - eslint: 9.35.0(jiti@2.5.1) + eslint: 9.35.0(jiti@2.7.0) eslint-visitor-keys: 3.4.3 - '@eslint-community/eslint-utils@4.9.1(eslint@9.35.0(jiti@2.5.1))': + '@eslint-community/eslint-utils@4.9.1(eslint@9.35.0(jiti@2.7.0))': dependencies: - eslint: 9.35.0(jiti@2.5.1) + eslint: 9.35.0(jiti@2.7.0) eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.1': {} @@ -6473,11 +6783,11 @@ snapshots: '@types/yargs': 17.0.33 chalk: 4.1.2 - '@joshwooding/vite-plugin-react-docgen-typescript@0.6.4(typescript@5.9.2)(vite@7.2.7(@types/node@20.19.14)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1))': + '@joshwooding/vite-plugin-react-docgen-typescript@0.6.4(typescript@5.9.2)(vite@7.2.7(@types/node@20.19.14)(jiti@2.7.0)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0))': dependencies: glob: 13.0.0 react-docgen-typescript: 2.4.0(typescript@5.9.2) - vite: 7.2.7(@types/node@20.19.14)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1) + vite: 7.2.7(@types/node@20.19.14)(jiti@2.7.0)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0) optionalDependencies: typescript: 5.9.2 @@ -6576,6 +6886,13 @@ snapshots: '@tybys/wasm-util': 0.10.1 optional: true + '@napi-rs/wasm-runtime@1.2.2(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)': + dependencies: + '@emnapi/core': 1.11.2 + '@emnapi/runtime': 1.11.2 + '@tybys/wasm-util': 0.10.3 + optional: true + '@next/bundle-analyzer@15.5.3': dependencies: webpack-bundle-analyzer: 4.10.1 @@ -6647,6 +6964,133 @@ snapshots: '@opentelemetry/api@1.9.0': optional: true + '@oxc-parser/binding-android-arm-eabi@0.142.0': + optional: true + + '@oxc-parser/binding-android-arm64@0.142.0': + optional: true + + '@oxc-parser/binding-darwin-arm64@0.142.0': + optional: true + + '@oxc-parser/binding-darwin-x64@0.142.0': + optional: true + + '@oxc-parser/binding-freebsd-x64@0.142.0': + optional: true + + '@oxc-parser/binding-linux-arm-gnueabihf@0.142.0': + optional: true + + '@oxc-parser/binding-linux-arm-musleabihf@0.142.0': + optional: true + + '@oxc-parser/binding-linux-arm64-gnu@0.142.0': + optional: true + + '@oxc-parser/binding-linux-arm64-musl@0.142.0': + optional: true + + '@oxc-parser/binding-linux-ppc64-gnu@0.142.0': + optional: true + + '@oxc-parser/binding-linux-riscv64-gnu@0.142.0': + optional: true + + '@oxc-parser/binding-linux-riscv64-musl@0.142.0': + optional: true + + '@oxc-parser/binding-linux-s390x-gnu@0.142.0': + optional: true + + '@oxc-parser/binding-linux-x64-gnu@0.142.0': + optional: true + + '@oxc-parser/binding-linux-x64-musl@0.142.0': + optional: true + + '@oxc-parser/binding-openharmony-arm64@0.142.0': + optional: true + + '@oxc-parser/binding-wasm32-wasi@0.142.0': + dependencies: + '@emnapi/core': 1.11.2 + '@emnapi/runtime': 1.11.2 + '@napi-rs/wasm-runtime': 1.2.2(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2) + optional: true + + '@oxc-parser/binding-win32-arm64-msvc@0.142.0': + optional: true + + '@oxc-parser/binding-win32-ia32-msvc@0.142.0': + optional: true + + '@oxc-parser/binding-win32-x64-msvc@0.142.0': + optional: true + + '@oxc-project/types@0.142.0': {} + + '@oxc-resolver/binding-android-arm-eabi@11.24.2': + optional: true + + '@oxc-resolver/binding-android-arm64@11.24.2': + optional: true + + '@oxc-resolver/binding-darwin-arm64@11.24.2': + optional: true + + '@oxc-resolver/binding-darwin-x64@11.24.2': + optional: true + + '@oxc-resolver/binding-freebsd-x64@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-arm-gnueabihf@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-arm-musleabihf@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-arm64-gnu@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-arm64-musl@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-ppc64-gnu@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-riscv64-gnu@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-riscv64-musl@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-s390x-gnu@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-x64-gnu@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-x64-musl@11.24.2': + optional: true + + '@oxc-resolver/binding-openharmony-arm64@11.24.2': + optional: true + + '@oxc-resolver/binding-wasm32-wasi@11.24.2': + dependencies: + '@emnapi/core': 1.11.2 + '@emnapi/runtime': 1.11.2 + '@napi-rs/wasm-runtime': 1.2.2(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2) + optional: true + + '@oxc-resolver/binding-win32-arm64-msvc@11.24.2': + optional: true + + '@oxc-resolver/binding-win32-x64-msvc@11.24.2': + optional: true + '@phc/format@1.0.0': {} '@playwright/test@1.57.0': @@ -6779,25 +7223,25 @@ snapshots: storybook: 10.2.13(@testing-library/dom@10.4.0)(prettier@3.6.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) ts-dedent: 2.2.0 - '@storybook/builder-vite@10.2.8(esbuild@0.25.9)(rollup@4.59.0)(storybook@10.2.13(@testing-library/dom@10.4.0)(prettier@3.6.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@7.2.7(@types/node@20.19.14)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1))': + '@storybook/builder-vite@10.2.8(esbuild@0.25.9)(rollup@4.59.0)(storybook@10.2.13(@testing-library/dom@10.4.0)(prettier@3.6.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@7.2.7(@types/node@20.19.14)(jiti@2.7.0)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0))': dependencies: - '@storybook/csf-plugin': 10.2.8(esbuild@0.25.9)(rollup@4.59.0)(storybook@10.2.13(@testing-library/dom@10.4.0)(prettier@3.6.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@7.2.7(@types/node@20.19.14)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)) + '@storybook/csf-plugin': 10.2.8(esbuild@0.25.9)(rollup@4.59.0)(storybook@10.2.13(@testing-library/dom@10.4.0)(prettier@3.6.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@7.2.7(@types/node@20.19.14)(jiti@2.7.0)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0)) storybook: 10.2.13(@testing-library/dom@10.4.0)(prettier@3.6.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) ts-dedent: 2.2.0 - vite: 7.2.7(@types/node@20.19.14)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1) + vite: 7.2.7(@types/node@20.19.14)(jiti@2.7.0)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0) transitivePeerDependencies: - esbuild - rollup - webpack - '@storybook/csf-plugin@10.2.8(esbuild@0.25.9)(rollup@4.59.0)(storybook@10.2.13(@testing-library/dom@10.4.0)(prettier@3.6.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@7.2.7(@types/node@20.19.14)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1))': + '@storybook/csf-plugin@10.2.8(esbuild@0.25.9)(rollup@4.59.0)(storybook@10.2.13(@testing-library/dom@10.4.0)(prettier@3.6.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@7.2.7(@types/node@20.19.14)(jiti@2.7.0)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0))': dependencies: storybook: 10.2.13(@testing-library/dom@10.4.0)(prettier@3.6.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) unplugin: 2.3.11 optionalDependencies: esbuild: 0.25.9 rollup: 4.59.0 - vite: 7.2.7(@types/node@20.19.14)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1) + vite: 7.2.7(@types/node@20.19.14)(jiti@2.7.0)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0) '@storybook/global@5.0.0': {} @@ -6806,18 +7250,18 @@ snapshots: react: 19.1.0 react-dom: 19.1.0(react@19.1.0) - '@storybook/nextjs-vite@10.2.8(@babel/core@7.28.4)(esbuild@0.25.9)(next@15.5.10(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(rollup@4.59.0)(storybook@10.2.13(@testing-library/dom@10.4.0)(prettier@3.6.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(typescript@5.9.2)(vite@7.2.7(@types/node@20.19.14)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1))': + '@storybook/nextjs-vite@10.2.8(@babel/core@7.28.4)(esbuild@0.25.9)(next@15.5.10(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(rollup@4.59.0)(storybook@10.2.13(@testing-library/dom@10.4.0)(prettier@3.6.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(typescript@5.9.2)(vite@7.2.7(@types/node@20.19.14)(jiti@2.7.0)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0))': dependencies: - '@storybook/builder-vite': 10.2.8(esbuild@0.25.9)(rollup@4.59.0)(storybook@10.2.13(@testing-library/dom@10.4.0)(prettier@3.6.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@7.2.7(@types/node@20.19.14)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)) + '@storybook/builder-vite': 10.2.8(esbuild@0.25.9)(rollup@4.59.0)(storybook@10.2.13(@testing-library/dom@10.4.0)(prettier@3.6.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@7.2.7(@types/node@20.19.14)(jiti@2.7.0)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0)) '@storybook/react': 10.2.8(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(storybook@10.2.13(@testing-library/dom@10.4.0)(prettier@3.6.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(typescript@5.9.2) - '@storybook/react-vite': 10.2.8(esbuild@0.25.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(rollup@4.59.0)(storybook@10.2.13(@testing-library/dom@10.4.0)(prettier@3.6.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(typescript@5.9.2)(vite@7.2.7(@types/node@20.19.14)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)) + '@storybook/react-vite': 10.2.8(esbuild@0.25.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(rollup@4.59.0)(storybook@10.2.13(@testing-library/dom@10.4.0)(prettier@3.6.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(typescript@5.9.2)(vite@7.2.7(@types/node@20.19.14)(jiti@2.7.0)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0)) next: 15.5.10(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) react: 19.1.0 react-dom: 19.1.0(react@19.1.0) storybook: 10.2.13(@testing-library/dom@10.4.0)(prettier@3.6.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) styled-jsx: 5.1.6(@babel/core@7.28.4)(react@19.1.0) - vite: 7.2.7(@types/node@20.19.14)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1) - vite-plugin-storybook-nextjs: 3.1.12(next@15.5.10(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(storybook@10.2.13(@testing-library/dom@10.4.0)(prettier@3.6.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(typescript@5.9.2)(vite@7.2.7(@types/node@20.19.14)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)) + vite: 7.2.7(@types/node@20.19.14)(jiti@2.7.0)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0) + vite-plugin-storybook-nextjs: 3.1.12(next@15.5.10(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(storybook@10.2.13(@testing-library/dom@10.4.0)(prettier@3.6.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(typescript@5.9.2)(vite@7.2.7(@types/node@20.19.14)(jiti@2.7.0)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0)) optionalDependencies: typescript: 5.9.2 transitivePeerDependencies: @@ -6834,11 +7278,11 @@ snapshots: react-dom: 19.1.0(react@19.1.0) storybook: 10.2.13(@testing-library/dom@10.4.0)(prettier@3.6.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@storybook/react-vite@10.2.8(esbuild@0.25.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(rollup@4.59.0)(storybook@10.2.13(@testing-library/dom@10.4.0)(prettier@3.6.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(typescript@5.9.2)(vite@7.2.7(@types/node@20.19.14)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1))': + '@storybook/react-vite@10.2.8(esbuild@0.25.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(rollup@4.59.0)(storybook@10.2.13(@testing-library/dom@10.4.0)(prettier@3.6.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(typescript@5.9.2)(vite@7.2.7(@types/node@20.19.14)(jiti@2.7.0)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0))': dependencies: - '@joshwooding/vite-plugin-react-docgen-typescript': 0.6.4(typescript@5.9.2)(vite@7.2.7(@types/node@20.19.14)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)) + '@joshwooding/vite-plugin-react-docgen-typescript': 0.6.4(typescript@5.9.2)(vite@7.2.7(@types/node@20.19.14)(jiti@2.7.0)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0)) '@rollup/pluginutils': 5.3.0(rollup@4.59.0) - '@storybook/builder-vite': 10.2.8(esbuild@0.25.9)(rollup@4.59.0)(storybook@10.2.13(@testing-library/dom@10.4.0)(prettier@3.6.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@7.2.7(@types/node@20.19.14)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)) + '@storybook/builder-vite': 10.2.8(esbuild@0.25.9)(rollup@4.59.0)(storybook@10.2.13(@testing-library/dom@10.4.0)(prettier@3.6.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@7.2.7(@types/node@20.19.14)(jiti@2.7.0)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0)) '@storybook/react': 10.2.8(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(storybook@10.2.13(@testing-library/dom@10.4.0)(prettier@3.6.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(typescript@5.9.2) empathic: 2.0.0 magic-string: 0.30.21 @@ -6848,7 +7292,7 @@ snapshots: resolve: 1.22.10 storybook: 10.2.13(@testing-library/dom@10.4.0)(prettier@3.6.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) tsconfig-paths: 4.2.0 - vite: 7.2.7(@types/node@20.19.14)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1) + vite: 7.2.7(@types/node@20.19.14)(jiti@2.7.0)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0) transitivePeerDependencies: - esbuild - rollup @@ -7042,6 +7486,11 @@ snapshots: tslib: 2.8.1 optional: true + '@tybys/wasm-util@0.10.3': + dependencies: + tslib: 2.8.1 + optional: true + '@types/aria-query@5.0.4': {} '@types/babel__core@7.20.5': @@ -7210,15 +7659,15 @@ snapshots: dependencies: '@types/yargs-parser': 21.0.3 - '@typescript-eslint/eslint-plugin@8.43.0(@typescript-eslint/parser@8.43.0(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.2))(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.2)': + '@typescript-eslint/eslint-plugin@8.43.0(@typescript-eslint/parser@8.43.0(eslint@9.35.0(jiti@2.7.0))(typescript@5.9.2))(eslint@9.35.0(jiti@2.7.0))(typescript@5.9.2)': dependencies: '@eslint-community/regexpp': 4.12.1 - '@typescript-eslint/parser': 8.43.0(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.2) + '@typescript-eslint/parser': 8.43.0(eslint@9.35.0(jiti@2.7.0))(typescript@5.9.2) '@typescript-eslint/scope-manager': 8.43.0 - '@typescript-eslint/type-utils': 8.43.0(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.2) - '@typescript-eslint/utils': 8.43.0(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.2) + '@typescript-eslint/type-utils': 8.43.0(eslint@9.35.0(jiti@2.7.0))(typescript@5.9.2) + '@typescript-eslint/utils': 8.43.0(eslint@9.35.0(jiti@2.7.0))(typescript@5.9.2) '@typescript-eslint/visitor-keys': 8.43.0 - eslint: 9.35.0(jiti@2.5.1) + eslint: 9.35.0(jiti@2.7.0) graphemer: 1.4.0 ignore: 7.0.5 natural-compare: 1.4.0 @@ -7227,14 +7676,14 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.43.0(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.2)': + '@typescript-eslint/parser@8.43.0(eslint@9.35.0(jiti@2.7.0))(typescript@5.9.2)': dependencies: '@typescript-eslint/scope-manager': 8.43.0 '@typescript-eslint/types': 8.43.0 '@typescript-eslint/typescript-estree': 8.43.0(typescript@5.9.2) '@typescript-eslint/visitor-keys': 8.43.0 debug: 4.4.1(supports-color@5.5.0) - eslint: 9.35.0(jiti@2.5.1) + eslint: 9.35.0(jiti@2.7.0) typescript: 5.9.2 transitivePeerDependencies: - supports-color @@ -7275,13 +7724,13 @@ snapshots: dependencies: typescript: 5.9.2 - '@typescript-eslint/type-utils@8.43.0(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.2)': + '@typescript-eslint/type-utils@8.43.0(eslint@9.35.0(jiti@2.7.0))(typescript@5.9.2)': dependencies: '@typescript-eslint/types': 8.43.0 '@typescript-eslint/typescript-estree': 8.43.0(typescript@5.9.2) - '@typescript-eslint/utils': 8.43.0(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.2) + '@typescript-eslint/utils': 8.43.0(eslint@9.35.0(jiti@2.7.0))(typescript@5.9.2) debug: 4.4.1(supports-color@5.5.0) - eslint: 9.35.0(jiti@2.5.1) + eslint: 9.35.0(jiti@2.7.0) ts-api-utils: 2.1.0(typescript@5.9.2) typescript: 5.9.2 transitivePeerDependencies: @@ -7322,24 +7771,24 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.43.0(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.2)': + '@typescript-eslint/utils@8.43.0(eslint@9.35.0(jiti@2.7.0))(typescript@5.9.2)': dependencies: - '@eslint-community/eslint-utils': 4.9.0(eslint@9.35.0(jiti@2.5.1)) + '@eslint-community/eslint-utils': 4.9.0(eslint@9.35.0(jiti@2.7.0)) '@typescript-eslint/scope-manager': 8.43.0 '@typescript-eslint/types': 8.43.0 '@typescript-eslint/typescript-estree': 8.43.0(typescript@5.9.2) - eslint: 9.35.0(jiti@2.5.1) + eslint: 9.35.0(jiti@2.7.0) typescript: 5.9.2 transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.55.0(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.2)': + '@typescript-eslint/utils@8.55.0(eslint@9.35.0(jiti@2.7.0))(typescript@5.9.2)': dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.35.0(jiti@2.5.1)) + '@eslint-community/eslint-utils': 4.9.1(eslint@9.35.0(jiti@2.7.0)) '@typescript-eslint/scope-manager': 8.55.0 '@typescript-eslint/types': 8.55.0 '@typescript-eslint/typescript-estree': 8.55.0(typescript@5.9.2) - eslint: 9.35.0(jiti@2.5.1) + eslint: 9.35.0(jiti@2.7.0) typescript: 5.9.2 transitivePeerDependencies: - supports-color @@ -7428,7 +7877,7 @@ snapshots: optionalDependencies: maplibre-gl: 5.15.0 - '@vitejs/plugin-react@5.0.2(vite@7.2.7(@types/node@20.19.14)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1))': + '@vitejs/plugin-react@5.0.2(vite@7.2.7(@types/node@20.19.14)(jiti@2.7.0)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0))': dependencies: '@babel/core': 7.28.4 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.4) @@ -7436,7 +7885,7 @@ snapshots: '@rolldown/pluginutils': 1.0.0-beta.34 '@types/babel__core': 7.20.5 react-refresh: 0.17.0 - vite: 7.2.7(@types/node@20.19.14)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1) + vite: 7.2.7(@types/node@20.19.14)(jiti@2.7.0)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0) transitivePeerDependencies: - supports-color @@ -7453,7 +7902,7 @@ snapshots: obug: 2.1.1 std-env: 3.10.0 tinyrainbow: 3.0.3 - vitest: 4.0.15(@opentelemetry/api@1.9.0)(@types/node@20.19.14)(@vitest/ui@4.0.15)(happy-dom@20.8.9)(jiti@2.5.1)(jsdom@26.1.0(canvas@3.2.0))(lightningcss@1.30.1)(msw@2.7.0(@types/node@20.19.14)(typescript@5.9.2))(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1) + vitest: 4.0.15(@opentelemetry/api@1.9.0)(@types/node@20.19.14)(@vitest/ui@4.0.15)(happy-dom@20.8.9)(jiti@2.7.0)(jsdom@26.1.0(canvas@3.2.0))(lightningcss@1.30.1)(msw@2.7.0(@types/node@20.19.14)(typescript@5.9.2))(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0) transitivePeerDependencies: - supports-color @@ -7474,14 +7923,14 @@ snapshots: chai: 6.2.1 tinyrainbow: 3.0.3 - '@vitest/mocker@4.0.15(msw@2.7.0(@types/node@20.19.14)(typescript@5.9.2))(vite@7.2.7(@types/node@20.19.14)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1))': + '@vitest/mocker@4.0.15(msw@2.7.0(@types/node@20.19.14)(typescript@5.9.2))(vite@7.2.7(@types/node@20.19.14)(jiti@2.7.0)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.0.15 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: msw: 2.7.0(@types/node@20.19.14)(typescript@5.9.2) - vite: 7.2.7(@types/node@20.19.14)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1) + vite: 7.2.7(@types/node@20.19.14)(jiti@2.7.0)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0) '@vitest/pretty-format@3.2.4': dependencies: @@ -7517,7 +7966,7 @@ snapshots: sirv: 3.0.2 tinyglobby: 0.2.15 tinyrainbow: 3.0.3 - vitest: 4.0.15(@opentelemetry/api@1.9.0)(@types/node@20.19.14)(@vitest/ui@4.0.15)(happy-dom@20.8.9)(jiti@2.5.1)(jsdom@26.1.0(canvas@3.2.0))(lightningcss@1.30.1)(msw@2.7.0(@types/node@20.19.14)(typescript@5.9.2))(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1) + vitest: 4.0.15(@opentelemetry/api@1.9.0)(@types/node@20.19.14)(@vitest/ui@4.0.15)(happy-dom@20.8.9)(jiti@2.7.0)(jsdom@26.1.0(canvas@3.2.0))(lightningcss@1.30.1)(msw@2.7.0(@types/node@20.19.14)(typescript@5.9.2))(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0) '@vitest/utils@3.2.4': dependencies: @@ -8376,19 +8825,19 @@ snapshots: escape-string-regexp@5.0.0: {} - eslint-config-next@15.5.2(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.2): + eslint-config-next@15.5.2(eslint@9.35.0(jiti@2.7.0))(typescript@5.9.2): dependencies: '@next/eslint-plugin-next': 15.5.2 '@rushstack/eslint-patch': 1.12.0 - '@typescript-eslint/eslint-plugin': 8.43.0(@typescript-eslint/parser@8.43.0(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.2))(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.2) - '@typescript-eslint/parser': 8.43.0(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.2) - eslint: 9.35.0(jiti@2.5.1) + '@typescript-eslint/eslint-plugin': 8.43.0(@typescript-eslint/parser@8.43.0(eslint@9.35.0(jiti@2.7.0))(typescript@5.9.2))(eslint@9.35.0(jiti@2.7.0))(typescript@5.9.2) + '@typescript-eslint/parser': 8.43.0(eslint@9.35.0(jiti@2.7.0))(typescript@5.9.2) + eslint: 9.35.0(jiti@2.7.0) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.35.0(jiti@2.5.1)) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.43.0(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1)(eslint@9.35.0(jiti@2.5.1)) - eslint-plugin-jsx-a11y: 6.10.2(eslint@9.35.0(jiti@2.5.1)) - eslint-plugin-react: 7.37.5(eslint@9.35.0(jiti@2.5.1)) - eslint-plugin-react-hooks: 5.2.0(eslint@9.35.0(jiti@2.5.1)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.35.0(jiti@2.7.0)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.43.0(eslint@9.35.0(jiti@2.7.0))(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1)(eslint@9.35.0(jiti@2.7.0)) + eslint-plugin-jsx-a11y: 6.10.2(eslint@9.35.0(jiti@2.7.0)) + eslint-plugin-react: 7.37.5(eslint@9.35.0(jiti@2.7.0)) + eslint-plugin-react-hooks: 5.2.0(eslint@9.35.0(jiti@2.7.0)) optionalDependencies: typescript: 5.9.2 transitivePeerDependencies: @@ -8404,33 +8853,33 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.35.0(jiti@2.5.1)): + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.35.0(jiti@2.7.0)): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.1(supports-color@5.5.0) - eslint: 9.35.0(jiti@2.5.1) + eslint: 9.35.0(jiti@2.7.0) get-tsconfig: 4.10.1 is-bun-module: 2.0.0 stable-hash: 0.0.5 tinyglobby: 0.2.15 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.43.0(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1)(eslint@9.35.0(jiti@2.5.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.43.0(eslint@9.35.0(jiti@2.7.0))(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1)(eslint@9.35.0(jiti@2.7.0)) transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.43.0(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.35.0(jiti@2.5.1)): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.43.0(eslint@9.35.0(jiti@2.7.0))(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.35.0(jiti@2.7.0)): dependencies: debug: 3.2.7 optionalDependencies: - '@typescript-eslint/parser': 8.43.0(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.2) - eslint: 9.35.0(jiti@2.5.1) + '@typescript-eslint/parser': 8.43.0(eslint@9.35.0(jiti@2.7.0))(typescript@5.9.2) + eslint: 9.35.0(jiti@2.7.0) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.35.0(jiti@2.5.1)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.35.0(jiti@2.7.0)) transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.43.0(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1)(eslint@9.35.0(jiti@2.5.1)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.43.0(eslint@9.35.0(jiti@2.7.0))(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1)(eslint@9.35.0(jiti@2.7.0)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -8439,9 +8888,9 @@ snapshots: array.prototype.flatmap: 1.3.3 debug: 3.2.7 doctrine: 2.1.0 - eslint: 9.35.0(jiti@2.5.1) + eslint: 9.35.0(jiti@2.7.0) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.43.0(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.35.0(jiti@2.5.1)) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.43.0(eslint@9.35.0(jiti@2.7.0))(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.35.0(jiti@2.7.0)) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -8453,13 +8902,13 @@ snapshots: string.prototype.trimend: 1.0.9 tsconfig-paths: 3.15.0 optionalDependencies: - '@typescript-eslint/parser': 8.43.0(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.2) + '@typescript-eslint/parser': 8.43.0(eslint@9.35.0(jiti@2.7.0))(typescript@5.9.2) transitivePeerDependencies: - eslint-import-resolver-typescript - eslint-import-resolver-webpack - supports-color - eslint-plugin-jsx-a11y@6.10.2(eslint@9.35.0(jiti@2.5.1)): + eslint-plugin-jsx-a11y@6.10.2(eslint@9.35.0(jiti@2.7.0)): dependencies: aria-query: 5.3.2 array-includes: 3.1.9 @@ -8469,7 +8918,7 @@ snapshots: axobject-query: 4.1.0 damerau-levenshtein: 1.0.8 emoji-regex: 9.2.2 - eslint: 9.35.0(jiti@2.5.1) + eslint: 9.35.0(jiti@2.7.0) hasown: 2.0.2 jsx-ast-utils: 3.3.5 language-tags: 1.0.9 @@ -8478,15 +8927,15 @@ snapshots: safe-regex-test: 1.1.0 string.prototype.includes: 2.0.1 - eslint-plugin-no-secrets@2.2.1(eslint@9.35.0(jiti@2.5.1)): + eslint-plugin-no-secrets@2.2.1(eslint@9.35.0(jiti@2.7.0)): dependencies: - eslint: 9.35.0(jiti@2.5.1) + eslint: 9.35.0(jiti@2.7.0) - eslint-plugin-react-hooks@5.2.0(eslint@9.35.0(jiti@2.5.1)): + eslint-plugin-react-hooks@5.2.0(eslint@9.35.0(jiti@2.7.0)): dependencies: - eslint: 9.35.0(jiti@2.5.1) + eslint: 9.35.0(jiti@2.7.0) - eslint-plugin-react@7.37.5(eslint@9.35.0(jiti@2.5.1)): + eslint-plugin-react@7.37.5(eslint@9.35.0(jiti@2.7.0)): dependencies: array-includes: 3.1.9 array.prototype.findlast: 1.2.5 @@ -8494,7 +8943,7 @@ snapshots: array.prototype.tosorted: 1.1.4 doctrine: 2.1.0 es-iterator-helpers: 1.2.1 - eslint: 9.35.0(jiti@2.5.1) + eslint: 9.35.0(jiti@2.7.0) estraverse: 5.3.0 hasown: 2.0.2 jsx-ast-utils: 3.3.5 @@ -8508,10 +8957,10 @@ snapshots: string.prototype.matchall: 4.0.12 string.prototype.repeat: 1.0.0 - eslint-plugin-storybook@10.2.8(eslint@9.35.0(jiti@2.5.1))(storybook@10.2.13(@testing-library/dom@10.4.0)(prettier@3.6.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(typescript@5.9.2): + eslint-plugin-storybook@10.2.8(eslint@9.35.0(jiti@2.7.0))(storybook@10.2.13(@testing-library/dom@10.4.0)(prettier@3.6.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(typescript@5.9.2): dependencies: - '@typescript-eslint/utils': 8.55.0(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.2) - eslint: 9.35.0(jiti@2.5.1) + '@typescript-eslint/utils': 8.55.0(eslint@9.35.0(jiti@2.7.0))(typescript@5.9.2) + eslint: 9.35.0(jiti@2.7.0) storybook: 10.2.13(@testing-library/dom@10.4.0)(prettier@3.6.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) transitivePeerDependencies: - supports-color @@ -8526,9 +8975,9 @@ snapshots: eslint-visitor-keys@4.2.1: {} - eslint@9.35.0(jiti@2.5.1): + eslint@9.35.0(jiti@2.7.0): dependencies: - '@eslint-community/eslint-utils': 4.9.0(eslint@9.35.0(jiti@2.5.1)) + '@eslint-community/eslint-utils': 4.9.0(eslint@9.35.0(jiti@2.7.0)) '@eslint-community/regexpp': 4.12.1 '@eslint/config-array': 0.21.0 '@eslint/config-helpers': 0.3.1 @@ -8564,7 +9013,7 @@ snapshots: natural-compare: 1.4.0 optionator: 0.9.4 optionalDependencies: - jiti: 2.5.1 + jiti: 2.7.0 transitivePeerDependencies: - supports-color @@ -8688,6 +9137,10 @@ snapshots: dependencies: format: 0.2.2 + fd-package-json@2.0.0: + dependencies: + walk-up-path: 4.0.0 + fdir@6.5.0(picomatch@4.0.4): optionalDependencies: picomatch: 4.0.4 @@ -8757,6 +9210,10 @@ snapshots: format@0.2.2: {} + formatly@0.3.0: + dependencies: + fd-package-json: 2.0.0 + from@0.1.7: {} fsevents@2.3.2: @@ -8816,6 +9273,10 @@ snapshots: dependencies: resolve-pkg-maps: 1.0.0 + get-tsconfig@4.14.0: + dependencies: + resolve-pkg-maps: 1.0.0 + get-value@2.0.6: {} github-from-package@0.0.0: {} @@ -9425,6 +9886,8 @@ snapshots: jiti@2.5.1: {} + jiti@2.7.0: {} + joi@17.13.3: dependencies: '@hapi/hoek': 9.3.0 @@ -9533,6 +9996,22 @@ snapshots: kind-of@6.0.3: {} + knip@6.31.0: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + formatly: 0.3.0 + get-tsconfig: 4.14.0 + jiti: 2.7.0 + oxc-parser: 0.142.0 + oxc-resolver: 11.24.2 + picomatch: 4.0.4 + smol-toml: 1.7.1 + strip-json-comments: 5.0.3 + tinyglobby: 0.2.17 + unbash: 4.0.4 + yaml: 2.9.0 + zod: 4.4.3 + language-subtag-registry@0.3.23: {} language-tags@1.0.9: @@ -10389,6 +10868,53 @@ snapshots: object-keys: 1.1.1 safe-push-apply: 1.0.0 + oxc-parser@0.142.0: + dependencies: + '@oxc-project/types': 0.142.0 + optionalDependencies: + '@oxc-parser/binding-android-arm-eabi': 0.142.0 + '@oxc-parser/binding-android-arm64': 0.142.0 + '@oxc-parser/binding-darwin-arm64': 0.142.0 + '@oxc-parser/binding-darwin-x64': 0.142.0 + '@oxc-parser/binding-freebsd-x64': 0.142.0 + '@oxc-parser/binding-linux-arm-gnueabihf': 0.142.0 + '@oxc-parser/binding-linux-arm-musleabihf': 0.142.0 + '@oxc-parser/binding-linux-arm64-gnu': 0.142.0 + '@oxc-parser/binding-linux-arm64-musl': 0.142.0 + '@oxc-parser/binding-linux-ppc64-gnu': 0.142.0 + '@oxc-parser/binding-linux-riscv64-gnu': 0.142.0 + '@oxc-parser/binding-linux-riscv64-musl': 0.142.0 + '@oxc-parser/binding-linux-s390x-gnu': 0.142.0 + '@oxc-parser/binding-linux-x64-gnu': 0.142.0 + '@oxc-parser/binding-linux-x64-musl': 0.142.0 + '@oxc-parser/binding-openharmony-arm64': 0.142.0 + '@oxc-parser/binding-wasm32-wasi': 0.142.0 + '@oxc-parser/binding-win32-arm64-msvc': 0.142.0 + '@oxc-parser/binding-win32-ia32-msvc': 0.142.0 + '@oxc-parser/binding-win32-x64-msvc': 0.142.0 + + oxc-resolver@11.24.2: + optionalDependencies: + '@oxc-resolver/binding-android-arm-eabi': 11.24.2 + '@oxc-resolver/binding-android-arm64': 11.24.2 + '@oxc-resolver/binding-darwin-arm64': 11.24.2 + '@oxc-resolver/binding-darwin-x64': 11.24.2 + '@oxc-resolver/binding-freebsd-x64': 11.24.2 + '@oxc-resolver/binding-linux-arm-gnueabihf': 11.24.2 + '@oxc-resolver/binding-linux-arm-musleabihf': 11.24.2 + '@oxc-resolver/binding-linux-arm64-gnu': 11.24.2 + '@oxc-resolver/binding-linux-arm64-musl': 11.24.2 + '@oxc-resolver/binding-linux-ppc64-gnu': 11.24.2 + '@oxc-resolver/binding-linux-riscv64-gnu': 11.24.2 + '@oxc-resolver/binding-linux-riscv64-musl': 11.24.2 + '@oxc-resolver/binding-linux-s390x-gnu': 11.24.2 + '@oxc-resolver/binding-linux-x64-gnu': 11.24.2 + '@oxc-resolver/binding-linux-x64-musl': 11.24.2 + '@oxc-resolver/binding-openharmony-arm64': 11.24.2 + '@oxc-resolver/binding-wasm32-wasi': 11.24.2 + '@oxc-resolver/binding-win32-arm64-msvc': 11.24.2 + '@oxc-resolver/binding-win32-x64-msvc': 11.24.2 + p-limit@3.1.0: dependencies: yocto-queue: 0.1.0 @@ -11178,6 +11704,8 @@ snapshots: ansi-styles: 6.2.3 is-fullwidth-code-point: 5.1.0 + smol-toml@1.7.1: {} + sort-asc@0.2.0: {} sort-desc@0.2.0: {} @@ -11392,6 +11920,8 @@ snapshots: strip-json-comments@3.1.1: {} + strip-json-comments@5.0.3: {} + styled-jsx@5.1.6(@babel/core@7.28.4)(react@19.1.0): dependencies: client-only: 0.0.1 @@ -11477,6 +12007,11 @@ snapshots: fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + tinyqueue@3.0.0: {} tinyrainbow@2.0.0: {} @@ -11628,6 +12163,8 @@ snapshots: uglify-js@3.19.3: optional: true + unbash@4.0.4: {} + unbox-primitive@1.1.0: dependencies: call-bound: 1.0.4 @@ -11766,7 +12303,7 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite-plugin-storybook-nextjs@3.1.12(next@15.5.10(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(storybook@10.2.13(@testing-library/dom@10.4.0)(prettier@3.6.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(typescript@5.9.2)(vite@7.2.7(@types/node@20.19.14)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)): + vite-plugin-storybook-nextjs@3.1.12(next@15.5.10(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(storybook@10.2.13(@testing-library/dom@10.4.0)(prettier@3.6.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(typescript@5.9.2)(vite@7.2.7(@types/node@20.19.14)(jiti@2.7.0)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0)): dependencies: '@next/env': 16.0.0 image-size: 2.0.2 @@ -11775,24 +12312,24 @@ snapshots: next: 15.5.10(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) storybook: 10.2.13(@testing-library/dom@10.4.0)(prettier@3.6.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) ts-dedent: 2.2.0 - vite: 7.2.7(@types/node@20.19.14)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1) - vite-tsconfig-paths: 5.1.4(typescript@5.9.2)(vite@7.2.7(@types/node@20.19.14)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)) + vite: 7.2.7(@types/node@20.19.14)(jiti@2.7.0)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0) + vite-tsconfig-paths: 5.1.4(typescript@5.9.2)(vite@7.2.7(@types/node@20.19.14)(jiti@2.7.0)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0)) transitivePeerDependencies: - supports-color - typescript - vite-tsconfig-paths@5.1.4(typescript@5.9.2)(vite@7.2.7(@types/node@20.19.14)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)): + vite-tsconfig-paths@5.1.4(typescript@5.9.2)(vite@7.2.7(@types/node@20.19.14)(jiti@2.7.0)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0)): dependencies: debug: 4.4.1(supports-color@5.5.0) globrex: 0.1.2 tsconfck: 3.1.6(typescript@5.9.2) optionalDependencies: - vite: 7.2.7(@types/node@20.19.14)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1) + vite: 7.2.7(@types/node@20.19.14)(jiti@2.7.0)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0) transitivePeerDependencies: - supports-color - typescript - vite@7.2.7(@types/node@20.19.14)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1): + vite@7.2.7(@types/node@20.19.14)(jiti@2.7.0)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0): dependencies: esbuild: 0.25.9 fdir: 6.5.0(picomatch@4.0.4) @@ -11803,16 +12340,16 @@ snapshots: optionalDependencies: '@types/node': 20.19.14 fsevents: 2.3.3 - jiti: 2.5.1 + jiti: 2.7.0 lightningcss: 1.30.1 terser: 5.44.0 tsx: 4.20.6 - yaml: 2.8.1 + yaml: 2.9.0 - vitest@4.0.15(@opentelemetry/api@1.9.0)(@types/node@20.19.14)(@vitest/ui@4.0.15)(happy-dom@20.8.9)(jiti@2.5.1)(jsdom@26.1.0(canvas@3.2.0))(lightningcss@1.30.1)(msw@2.7.0(@types/node@20.19.14)(typescript@5.9.2))(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1): + vitest@4.0.15(@opentelemetry/api@1.9.0)(@types/node@20.19.14)(@vitest/ui@4.0.15)(happy-dom@20.8.9)(jiti@2.7.0)(jsdom@26.1.0(canvas@3.2.0))(lightningcss@1.30.1)(msw@2.7.0(@types/node@20.19.14)(typescript@5.9.2))(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0): dependencies: '@vitest/expect': 4.0.15 - '@vitest/mocker': 4.0.15(msw@2.7.0(@types/node@20.19.14)(typescript@5.9.2))(vite@7.2.7(@types/node@20.19.14)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)) + '@vitest/mocker': 4.0.15(msw@2.7.0(@types/node@20.19.14)(typescript@5.9.2))(vite@7.2.7(@types/node@20.19.14)(jiti@2.7.0)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0)) '@vitest/pretty-format': 4.0.15 '@vitest/runner': 4.0.15 '@vitest/snapshot': 4.0.15 @@ -11829,7 +12366,7 @@ snapshots: tinyexec: 1.0.2 tinyglobby: 0.2.15 tinyrainbow: 3.0.3 - vite: 7.2.7(@types/node@20.19.14)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1) + vite: 7.2.7(@types/node@20.19.14)(jiti@2.7.0)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@opentelemetry/api': 1.9.0 @@ -11874,6 +12411,8 @@ snapshots: transitivePeerDependencies: - debug + walk-up-path@4.0.0: {} + wcwidth@1.0.1: dependencies: defaults: 1.0.4 @@ -12037,6 +12576,8 @@ snapshots: yaml@2.8.1: {} + yaml@2.9.0: {} + yargs-parser@21.1.1: {} yargs@17.7.2: @@ -12055,4 +12596,6 @@ snapshots: zod@4.1.8: {} + zod@4.4.3: {} + zwitch@2.0.4: {}