feat: production readiness improvements
## Error Handling - Add GlobalErrorBoundary with error classification and recovery - Add custom error types (SecurityError, ConnectionError, TimeoutError) - Fix ErrorAlert component syntax errors ## Offline Mode - Add offlineStore for offline state management - Implement message queue with localStorage persistence - Add exponential backoff reconnection (1s→60s) - Add OfflineIndicator component with status display - Queue messages when offline, auto-retry on reconnect ## Security Hardening - Add AES-256-GCM encryption for chat history storage - Add secure API key storage with OS keychain integration - Add security audit logging system - Add XSS prevention and input validation utilities - Add rate limiting and token generation helpers ## CI/CD (Gitea Actions) - Add .gitea/workflows/ci.yml for continuous integration - Add .gitea/workflows/release.yml for release automation - Support Windows Tauri build and release ## UI Components - Add LoadingSpinner, LoadingOverlay, LoadingDots components - Add MessageSkeleton, ConversationListSkeleton skeletons - Add EmptyMessages, EmptyConversations empty states - Integrate loading states in ChatArea and ConversationList ## E2E Tests - Fix WebSocket mock for streaming response tests - Fix approval endpoint route matching - Add store state exposure for testing - All 19 core-features tests now passing Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
228
.gitea/workflows/ci.yml
Normal file
228
.gitea/workflows/ci.yml
Normal file
@@ -0,0 +1,228 @@
|
||||
# ZCLAW Continuous Integration Workflow for Gitea
|
||||
# Runs on every push to main and all pull requests
|
||||
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
env:
|
||||
NODE_VERSION: '20'
|
||||
PNPM_VERSION: '9'
|
||||
RUST_VERSION: '1.78'
|
||||
|
||||
jobs:
|
||||
# ============================================================================
|
||||
# Lint and Type Check
|
||||
# ============================================================================
|
||||
lint:
|
||||
name: Lint & TypeCheck
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: node:20
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: ${{ env.PNPM_VERSION }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install root dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Install desktop dependencies
|
||||
working-directory: desktop
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Type check desktop
|
||||
working-directory: desktop
|
||||
run: pnpm typecheck
|
||||
|
||||
- name: Type check root
|
||||
run: pnpm exec tsc --noEmit
|
||||
|
||||
# ============================================================================
|
||||
# Unit Tests
|
||||
# ============================================================================
|
||||
test:
|
||||
name: Unit Tests
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: node:20
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: ${{ env.PNPM_VERSION }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install root dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Install desktop dependencies
|
||||
working-directory: desktop
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Run desktop unit tests
|
||||
working-directory: desktop
|
||||
run: pnpm test
|
||||
|
||||
- name: Run root unit tests
|
||||
run: pnpm test
|
||||
|
||||
# ============================================================================
|
||||
# Build Verification (Frontend only - no Tauri)
|
||||
# ============================================================================
|
||||
build-frontend:
|
||||
name: Build Frontend
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: node:20
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: ${{ env.PNPM_VERSION }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install desktop dependencies
|
||||
working-directory: desktop
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build frontend
|
||||
working-directory: desktop
|
||||
run: pnpm build
|
||||
|
||||
# ============================================================================
|
||||
# Rust Backend Check
|
||||
# ============================================================================
|
||||
rust-check:
|
||||
name: Rust Check
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: rust:1.78
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust components
|
||||
run: rustup component add clippy rustfmt
|
||||
|
||||
- name: Cache Rust dependencies
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: |
|
||||
desktop/src-tauri
|
||||
|
||||
- name: Check Rust formatting
|
||||
working-directory: desktop/src-tauri
|
||||
run: cargo fmt --all -- --check
|
||||
|
||||
- name: Run Clippy
|
||||
working-directory: desktop/src-tauri
|
||||
run: cargo clippy --all-targets --all-features -- -D warnings
|
||||
|
||||
- name: Check Rust build
|
||||
working-directory: desktop/src-tauri
|
||||
run: cargo check --all-targets
|
||||
|
||||
# ============================================================================
|
||||
# Security Scan
|
||||
# ============================================================================
|
||||
security:
|
||||
name: Security Scan
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: node:20
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: ${{ env.PNPM_VERSION }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
pnpm install --frozen-lockfile
|
||||
cd desktop && pnpm install --frozen-lockfile
|
||||
|
||||
- name: Run npm audit (root)
|
||||
run: pnpm audit --audit-level=high
|
||||
continue-on-error: true
|
||||
|
||||
- name: Run npm audit (desktop)
|
||||
working-directory: desktop
|
||||
run: pnpm audit --audit-level=high
|
||||
continue-on-error: true
|
||||
|
||||
# ============================================================================
|
||||
# E2E Tests (Optional - requires browser)
|
||||
# ============================================================================
|
||||
e2e:
|
||||
name: E2E Tests
|
||||
runs-on: ubuntu-latest
|
||||
needs: [lint, test]
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.42.0-jammy
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: ${{ env.PNPM_VERSION }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: desktop
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Install Playwright browsers
|
||||
working-directory: desktop
|
||||
run: pnpm exec playwright install chromium
|
||||
|
||||
- name: Run E2E tests
|
||||
working-directory: desktop
|
||||
run: pnpm test:e2e
|
||||
continue-on-error: true
|
||||
139
.gitea/workflows/release.yml
Normal file
139
.gitea/workflows/release.yml
Normal file
@@ -0,0 +1,139 @@
|
||||
# ZCLAW Release Workflow for Gitea
|
||||
# Builds Tauri application and creates Gitea Release
|
||||
# Triggered by pushing version tags (e.g., v0.2.0)
|
||||
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
env:
|
||||
NODE_VERSION: '20'
|
||||
PNPM_VERSION: '9'
|
||||
RUST_VERSION: '1.78'
|
||||
|
||||
jobs:
|
||||
# ============================================================================
|
||||
# Build Tauri Application for Windows
|
||||
# ============================================================================
|
||||
build-windows:
|
||||
name: Build Windows
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: ${{ env.PNPM_VERSION }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-action@stable
|
||||
|
||||
- name: Cache Rust dependencies
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: |
|
||||
desktop/src-tauri
|
||||
|
||||
- name: Install frontend dependencies
|
||||
working-directory: desktop
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Prepare OpenFang Runtime
|
||||
working-directory: desktop
|
||||
run: pnpm prepare:openfang-runtime
|
||||
|
||||
- name: Build Tauri application
|
||||
working-directory: desktop
|
||||
run: pnpm tauri:build:bundled
|
||||
|
||||
- name: Find installer
|
||||
id: find-installer
|
||||
shell: pwsh
|
||||
run: |
|
||||
$installer = Get-ChildItem -Path "desktop/src-tauri/target/release/bundle/nsis" -Filter "*.exe" -Recurse | Select-Object -First 1
|
||||
echo "INSTALLER_PATH=$($installer.FullName)" >> $env:GITEA_OUTPUT
|
||||
echo "INSTALLER_NAME=$($installer.Name)" >> $env:GITEA_OUTPUT
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: windows-installer
|
||||
path: ${{ steps.find-installer.outputs.INSTALLER_PATH }}
|
||||
retention-days: 30
|
||||
|
||||
# ============================================================================
|
||||
# Create Gitea Release
|
||||
# ============================================================================
|
||||
create-release:
|
||||
name: Create Release
|
||||
needs: build-windows
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Download Windows artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: windows-installer
|
||||
path: ./artifacts
|
||||
|
||||
- name: Get version from tag
|
||||
id: get_version
|
||||
run: echo "VERSION=${GITEA_REF#refs/tags/}" >> $GITEA_OUTPUT
|
||||
|
||||
- name: Create Gitea Release
|
||||
uses: actions/gitea-release@v1
|
||||
with:
|
||||
tag_name: ${{ gitea.ref_name }}
|
||||
name: ZCLAW ${{ steps.get_version.outputs.VERSION }}
|
||||
body: |
|
||||
## ZCLAW ${{ steps.get_version.outputs.VERSION }}
|
||||
|
||||
### Changes
|
||||
- See CHANGELOG.md for details
|
||||
|
||||
### Downloads
|
||||
- **Windows**: Download the `.exe` installer below
|
||||
|
||||
### System Requirements
|
||||
- Windows 10/11 (64-bit)
|
||||
draft: true
|
||||
prerelease: false
|
||||
files: |
|
||||
./artifacts/*.exe
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
|
||||
# ============================================================================
|
||||
# Build Summary
|
||||
# ============================================================================
|
||||
release-summary:
|
||||
name: Release Summary
|
||||
needs: [build-windows, create-release]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Release Summary
|
||||
run: |
|
||||
echo "## Release Build Complete"
|
||||
echo ""
|
||||
echo "**Tag**: ${{ gitea.ref_name }}"
|
||||
echo ""
|
||||
echo "### Artifacts"
|
||||
echo "- Windows installer uploaded to release"
|
||||
echo ""
|
||||
echo "### Next Steps"
|
||||
echo "1. Review the draft release"
|
||||
echo "2. Update release notes if needed"
|
||||
echo "3. Publish the release when ready"
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useRef, useCallback, useMemo, type CSSProperties, type RefObject, type MutableRefObject } from 'react';
|
||||
import { useState, useEffect, useRef, useCallback, useMemo, type MutableRefObject, type RefObject, type CSSProperties } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { List, type ListImperativeAPI } from 'react-window';
|
||||
import { useChatStore, Message } from '../store/chatStore';
|
||||
@@ -6,13 +6,14 @@ import { useConnectionStore } from '../store/connectionStore';
|
||||
import { useAgentStore } from '../store/agentStore';
|
||||
import { useConfigStore } from '../store/configStore';
|
||||
import { Paperclip, ChevronDown, Terminal, SquarePen, ArrowUp, MessageSquare, Download, Copy, Check } from 'lucide-react';
|
||||
import { Button, EmptyState } from './ui';
|
||||
import { Button, EmptyState, MessageListSkeleton, LoadingDots } from './ui';
|
||||
import { listItemVariants, defaultTransition, fadeInVariants } from '../lib/animations';
|
||||
import { FirstConversationPrompt } from './FirstConversationPrompt';
|
||||
import { MessageSearch } from './MessageSearch';
|
||||
import { OfflineIndicator } from './OfflineIndicator';
|
||||
import {
|
||||
useVirtualizedMessages,
|
||||
type VirtualizedMessageItem,
|
||||
type VirtualizedMessageItem
|
||||
} from '../lib/message-virtualization';
|
||||
|
||||
// Default heights for virtualized messages
|
||||
@@ -30,7 +31,7 @@ const VIRTUALIZATION_THRESHOLD = 100;
|
||||
|
||||
export function ChatArea() {
|
||||
const {
|
||||
messages, currentAgent, isStreaming, currentModel,
|
||||
messages, currentAgent, isStreaming, isLoading, currentModel,
|
||||
sendMessage: sendToGateway, setCurrentModel, initStreamListener,
|
||||
newConversation,
|
||||
} = useChatStore();
|
||||
@@ -105,7 +106,8 @@ export function ChatArea() {
|
||||
}, [messages, useVirtualization, scrollToBottom]);
|
||||
|
||||
const handleSend = () => {
|
||||
if (!input.trim() || isStreaming || !connected) return;
|
||||
if (!input.trim() || isStreaming) return;
|
||||
// Allow sending in offline mode - message will be queued
|
||||
sendToGateway(input);
|
||||
setInput('');
|
||||
};
|
||||
@@ -134,6 +136,7 @@ export function ChatArea() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header */}
|
||||
{/* Header */}
|
||||
<div className="h-14 border-b border-gray-100 dark:border-gray-800 flex items-center justify-between px-6 flex-shrink-0 bg-white dark:bg-gray-900">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -151,6 +154,8 @@ export function ChatArea() {
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Offline indicator in header */}
|
||||
<OfflineIndicator compact />
|
||||
{messages.length > 0 && (
|
||||
<MessageSearch onNavigateToMessage={handleNavigateToMessage} />
|
||||
)}
|
||||
@@ -171,9 +176,23 @@ export function ChatArea() {
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
<div ref={scrollRef} className="flex-1 overflow-y-auto custom-scrollbar p-6 space-y-6 bg-white dark:bg-gray-900">
|
||||
<div ref={scrollRef} className="flex-1 overflow-y-auto custom-scrollbar bg-white dark:bg-gray-900">
|
||||
<AnimatePresence mode="popLayout">
|
||||
{messages.length === 0 && (
|
||||
{/* Loading skeleton */}
|
||||
{isLoading && messages.length === 0 && (
|
||||
<motion.div
|
||||
key="loading-skeleton"
|
||||
variants={fadeInVariants}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
>
|
||||
<MessageListSkeleton count={3} />
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{!isLoading && messages.length === 0 && (
|
||||
<motion.div
|
||||
key="empty-state"
|
||||
variants={fadeInVariants}
|
||||
@@ -189,8 +208,8 @@ export function ChatArea() {
|
||||
) : (
|
||||
<EmptyState
|
||||
icon={<MessageSquare className="w-8 h-8" />}
|
||||
title="欢迎使用 ZCLAW"
|
||||
description={connected ? '发送消息开始对话' : '请先在设置中连接 Gateway'}
|
||||
title="Welcome to ZCLAW"
|
||||
description={connected ? 'Send a message to start the conversation.' : 'Please connect to Gateway first in Settings.'}
|
||||
/>
|
||||
)}
|
||||
</motion.div>
|
||||
@@ -242,13 +261,11 @@ export function ChatArea() {
|
||||
onChange={(e) => { setInput(e.target.value); adjustTextarea(); }}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={
|
||||
!connected
|
||||
? '请先连接 Gateway'
|
||||
: isStreaming
|
||||
? 'Agent 正在回复...'
|
||||
: `发送给 ${currentAgent?.name || 'ZCLAW'}`
|
||||
isStreaming
|
||||
? 'Agent 正在回复...'
|
||||
: `发送给 ${currentAgent?.name || 'ZCLAW'}${!connected ? ' (离线模式)' : ''}`
|
||||
}
|
||||
disabled={isStreaming || !connected}
|
||||
disabled={isStreaming}
|
||||
rows={1}
|
||||
className="w-full bg-transparent border-none focus:outline-none text-gray-700 dark:text-gray-200 placeholder-gray-400 dark:placeholder-gray-500 disabled:opacity-50 resize-none leading-relaxed mt-1"
|
||||
style={{ minHeight: '24px', maxHeight: '160px' }}
|
||||
@@ -289,8 +306,8 @@ export function ChatArea() {
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={handleSend}
|
||||
disabled={isStreaming || !input.trim() || !connected}
|
||||
className="w-8 h-8 rounded-full p-0 flex items-center justify-center bg-orange-500 hover:bg-orange-600 text-white"
|
||||
disabled={isStreaming || !input.trim()}
|
||||
className="w-8 h-8 rounded-full p-0 flex items-center justify-center bg-orange-500 hover:bg-orange-600 text-white disabled:opacity-50"
|
||||
aria-label="发送消息"
|
||||
>
|
||||
<ArrowUp className="w-4 h-4 text-white" />
|
||||
@@ -549,14 +566,10 @@ function MessageBubble({ message }: { message: Message }) {
|
||||
</div>
|
||||
<div className={isUser ? 'max-w-2xl' : 'flex-1 max-w-3xl'}>
|
||||
{isThinking ? (
|
||||
// 思考中指示器
|
||||
// Thinking indicator
|
||||
<div className="flex items-center gap-2 px-4 py-3 text-gray-500 dark:text-gray-400">
|
||||
<div className="flex gap-1">
|
||||
<span className="w-2 h-2 bg-gray-400 dark:bg-gray-500 rounded-full animate-bounce" style={{ animationDelay: '0ms' }} />
|
||||
<span className="w-2 h-2 bg-gray-400 dark:bg-gray-500 rounded-full animate-bounce" style={{ animationDelay: '150ms' }} />
|
||||
<span className="w-2 h-2 bg-gray-400 dark:bg-gray-500 rounded-full animate-bounce" style={{ animationDelay: '300ms' }} />
|
||||
</div>
|
||||
<span className="text-sm">思考中...</span>
|
||||
<LoadingDots />
|
||||
<span className="text-sm">Thinking...</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className={`p-4 shadow-sm ${isUser ? 'chat-bubble-user shadow-md' : 'chat-bubble-assistant'} relative group`}>
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
import { useChatStore } from '../store/chatStore';
|
||||
import { MessageSquare, Trash2, SquarePen } from 'lucide-react';
|
||||
import { EmptyConversations, ConversationListSkeleton } from './ui';
|
||||
|
||||
export function ConversationList() {
|
||||
const {
|
||||
conversations, currentConversationId, messages, agents, currentAgent,
|
||||
newConversation, switchConversation, deleteConversation,
|
||||
isLoading,
|
||||
} = useChatStore();
|
||||
|
||||
const hasActiveChat = messages.length > 0;
|
||||
|
||||
// Show skeleton during initial load
|
||||
if (isLoading && conversations.length === 0 && !hasActiveChat) {
|
||||
return <ConversationListSkeleton count={4} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
{/* Header */}
|
||||
@@ -86,11 +93,7 @@ export function ConversationList() {
|
||||
})}
|
||||
|
||||
{conversations.length === 0 && !hasActiveChat && (
|
||||
<div className="text-center py-8 text-xs text-gray-400">
|
||||
<MessageSquare className="w-8 h-8 mx-auto mb-2 opacity-30" />
|
||||
<p>暂无对话</p>
|
||||
<p className="mt-1">发送消息开始对话</p>
|
||||
</div>
|
||||
<EmptyConversations size="sm" className="h-auto" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
378
desktop/src/components/OfflineIndicator.tsx
Normal file
378
desktop/src/components/OfflineIndicator.tsx
Normal file
@@ -0,0 +1,378 @@
|
||||
/**
|
||||
* OfflineIndicator Component
|
||||
*
|
||||
* Displays offline mode status, pending message count, and reconnection info.
|
||||
* Shows a prominent banner when the app is offline with visual feedback.
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
WifiOff,
|
||||
CloudOff,
|
||||
RefreshCw,
|
||||
Clock,
|
||||
AlertCircle,
|
||||
CheckCircle,
|
||||
Send,
|
||||
X,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
} from 'lucide-react';
|
||||
import { useOfflineStore, type QueuedMessage } from '../store/offlineStore';
|
||||
import { useConnectionStore } from '../store/connectionStore';
|
||||
|
||||
interface OfflineIndicatorProps {
|
||||
/** Show compact version (minimal) */
|
||||
compact?: boolean;
|
||||
/** Show pending messages list */
|
||||
showQueue?: boolean;
|
||||
/** Additional CSS classes */
|
||||
className?: string;
|
||||
/** Callback when reconnect button is clicked */
|
||||
onReconnect?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format relative time
|
||||
*/
|
||||
function formatRelativeTime(timestamp: number): string {
|
||||
const seconds = Math.floor((Date.now() - timestamp) / 1000);
|
||||
|
||||
if (seconds < 60) return '刚刚';
|
||||
if (seconds < 3600) return `${Math.floor(seconds / 60)}分钟前`;
|
||||
if (seconds < 86400) return `${Math.floor(seconds / 3600)}小时前`;
|
||||
return `${Math.floor(seconds / 86400)}天前`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format reconnect delay for display
|
||||
*/
|
||||
function formatReconnectDelay(delay: number): string {
|
||||
if (delay < 1000) return '立即';
|
||||
if (delay < 60000) return `${Math.ceil(delay / 1000)}秒`;
|
||||
return `${Math.ceil(delay / 60000)}分钟`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate message content for display
|
||||
*/
|
||||
function truncateContent(content: string, maxLength: number = 50): string {
|
||||
if (content.length <= maxLength) return content;
|
||||
return content.slice(0, maxLength) + '...';
|
||||
}
|
||||
|
||||
/**
|
||||
* Full offline indicator with banner, queue, and reconnect info
|
||||
*/
|
||||
export function OfflineIndicator({
|
||||
compact = false,
|
||||
showQueue = true,
|
||||
className = '',
|
||||
onReconnect,
|
||||
}: OfflineIndicatorProps) {
|
||||
const {
|
||||
isOffline,
|
||||
isReconnecting,
|
||||
reconnectAttempt,
|
||||
nextReconnectDelay,
|
||||
queuedMessages,
|
||||
cancelReconnect,
|
||||
} = useOfflineStore();
|
||||
|
||||
const connect = useConnectionStore((s) => s.connect);
|
||||
|
||||
const [showMessageQueue, setShowMessageQueue] = useState(false);
|
||||
const [countdown, setCountdown] = useState<number | null>(null);
|
||||
|
||||
// Countdown timer for reconnection
|
||||
useEffect(() => {
|
||||
if (!isReconnecting || !nextReconnectDelay) {
|
||||
setCountdown(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const endTime = Date.now() + nextReconnectDelay;
|
||||
setCountdown(nextReconnectDelay);
|
||||
|
||||
const interval = setInterval(() => {
|
||||
const remaining = Math.max(0, endTime - Date.now());
|
||||
setCountdown(remaining);
|
||||
|
||||
if (remaining === 0) {
|
||||
clearInterval(interval);
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [isReconnecting, nextReconnectDelay]);
|
||||
|
||||
// Handle manual reconnect
|
||||
const handleReconnect = async () => {
|
||||
onReconnect?.();
|
||||
try {
|
||||
await connect();
|
||||
} catch (err) {
|
||||
console.error('[OfflineIndicator] Manual reconnect failed:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const pendingCount = queuedMessages.filter(
|
||||
(m) => m.status === 'pending' || m.status === 'failed'
|
||||
).length;
|
||||
|
||||
// Don't show if online and no pending messages
|
||||
if (!isOffline && pendingCount === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Compact version for headers/toolbars
|
||||
if (compact) {
|
||||
return (
|
||||
<div className={`flex items-center gap-2 ${className}`}>
|
||||
{isOffline ? (
|
||||
<>
|
||||
<CloudOff className="w-4 h-4 text-orange-500" />
|
||||
<span className="text-sm text-orange-500 font-medium">
|
||||
离线模式
|
||||
</span>
|
||||
{pendingCount > 0 && (
|
||||
<span className="text-xs bg-orange-100 dark:bg-orange-900/30 text-orange-600 dark:text-orange-400 px-1.5 py-0.5 rounded">
|
||||
{pendingCount} 条待发
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle className="w-4 h-4 text-green-500" />
|
||||
<span className="text-sm text-green-500">已恢复连接</span>
|
||||
{pendingCount > 0 && (
|
||||
<span className="text-xs bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400 px-1.5 py-0.5 rounded">
|
||||
发送中 {pendingCount} 条
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Full banner version
|
||||
return (
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
className={`${className}`}
|
||||
>
|
||||
{/* Main Banner */}
|
||||
<div
|
||||
className={`flex items-center gap-3 px-4 py-3 rounded-lg ${
|
||||
isOffline
|
||||
? 'bg-orange-50 dark:bg-orange-900/20 border border-orange-200 dark:border-orange-800'
|
||||
: 'bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800'
|
||||
}`}
|
||||
>
|
||||
{/* Status Icon */}
|
||||
<motion.div
|
||||
animate={isReconnecting ? { rotate: 360 } : {}}
|
||||
transition={
|
||||
isReconnecting
|
||||
? { duration: 1, repeat: Infinity, ease: 'linear' }
|
||||
: {}
|
||||
}
|
||||
>
|
||||
{isOffline ? (
|
||||
<WifiOff className="w-5 h-5 text-orange-500" />
|
||||
) : (
|
||||
<CheckCircle className="w-5 h-5 text-green-500" />
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
{/* Status Text */}
|
||||
<div className="flex-1">
|
||||
<div
|
||||
className={`text-sm font-medium ${
|
||||
isOffline ? 'text-orange-700 dark:text-orange-400' : 'text-green-700 dark:text-green-400'
|
||||
}`}
|
||||
>
|
||||
{isOffline ? '后端服务不可用' : '连接已恢复'}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{isReconnecting ? (
|
||||
<>
|
||||
正在尝试重连 ({reconnectAttempt}次)
|
||||
{countdown !== null && (
|
||||
<span className="ml-2">
|
||||
{formatReconnectDelay(countdown)}后重试
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : isOffline ? (
|
||||
'消息将保存在本地,连接后自动发送'
|
||||
) : pendingCount > 0 ? (
|
||||
`正在发送 ${pendingCount} 条排队消息...`
|
||||
) : (
|
||||
'所有消息已同步'
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2">
|
||||
{isOffline && !isReconnecting && (
|
||||
<button
|
||||
onClick={handleReconnect}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-white bg-orange-500 hover:bg-orange-600 rounded-md transition-colors"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
重连
|
||||
</button>
|
||||
)}
|
||||
{isReconnecting && (
|
||||
<button
|
||||
onClick={cancelReconnect}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-gray-600 dark:text-gray-300 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-md transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
取消
|
||||
</button>
|
||||
)}
|
||||
{showQueue && pendingCount > 0 && (
|
||||
<button
|
||||
onClick={() => setShowMessageQueue(!showMessageQueue)}
|
||||
className="flex items-center gap-1 px-2 py-1 text-xs text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 transition-colors"
|
||||
>
|
||||
{showMessageQueue ? (
|
||||
<ChevronUp className="w-4 h-4" />
|
||||
) : (
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
)}
|
||||
{pendingCount} 条待发
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Message Queue */}
|
||||
<AnimatePresence>
|
||||
{showMessageQueue && pendingCount > 0 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
className="mt-2 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden"
|
||||
>
|
||||
<div className="px-4 py-2 bg-gray-50 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
排队消息
|
||||
</span>
|
||||
</div>
|
||||
<div className="max-h-48 overflow-y-auto">
|
||||
{queuedMessages
|
||||
.filter((m) => m.status === 'pending' || m.status === 'failed')
|
||||
.map((msg) => (
|
||||
<QueuedMessageItem key={msg.id} message={msg} />
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Individual queued message item
|
||||
*/
|
||||
function QueuedMessageItem({ message }: { message: QueuedMessage }) {
|
||||
const { removeMessage } = useOfflineStore();
|
||||
|
||||
const statusConfig = {
|
||||
pending: { icon: Clock, color: 'text-gray-400', label: '等待中' },
|
||||
sending: { icon: Send, color: 'text-blue-500', label: '发送中' },
|
||||
failed: { icon: AlertCircle, color: 'text-red-500', label: '发送失败' },
|
||||
sent: { icon: CheckCircle, color: 'text-green-500', label: '已发送' },
|
||||
};
|
||||
|
||||
const config = statusConfig[message.status];
|
||||
const StatusIcon = config.icon;
|
||||
|
||||
return (
|
||||
<div className="flex items-start gap-3 px-4 py-2 border-b border-gray-100 dark:border-gray-800 last:border-b-0">
|
||||
<StatusIcon className={`w-4 h-4 mt-0.5 ${config.color}`} />
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm text-gray-700 dark:text-gray-300 truncate">
|
||||
{truncateContent(message.content)}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className="text-xs text-gray-400">
|
||||
{formatRelativeTime(message.timestamp)}
|
||||
</span>
|
||||
{message.status === 'failed' && message.lastError && (
|
||||
<span className="text-xs text-red-500">{message.lastError}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{message.status === 'failed' && (
|
||||
<button
|
||||
onClick={() => removeMessage(message.id)}
|
||||
className="p-1 text-gray-400 hover:text-red-500 transition-colors"
|
||||
title="删除消息"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal connection status indicator for headers
|
||||
*/
|
||||
export function ConnectionStatusBadge({ className = '' }: { className?: string }) {
|
||||
const connectionState = useConnectionStore((s) => s.connectionState);
|
||||
const queuedMessages = useOfflineStore((s) => s.queuedMessages);
|
||||
|
||||
const pendingCount = queuedMessages.filter(
|
||||
(m) => m.status === 'pending' || m.status === 'failed'
|
||||
).length;
|
||||
|
||||
const isConnected = connectionState === 'connected';
|
||||
|
||||
return (
|
||||
<div className={`flex items-center gap-1.5 ${className}`}>
|
||||
<span
|
||||
className={`w-2 h-2 rounded-full ${
|
||||
isConnected
|
||||
? 'bg-green-400'
|
||||
: connectionState === 'reconnecting'
|
||||
? 'bg-orange-400 animate-pulse'
|
||||
: 'bg-red-400'
|
||||
}`}
|
||||
/>
|
||||
<span
|
||||
className={`text-xs ${
|
||||
isConnected
|
||||
? 'text-green-500'
|
||||
: connectionState === 'reconnecting'
|
||||
? 'text-orange-500'
|
||||
: 'text-red-500'
|
||||
}`}
|
||||
>
|
||||
{isConnected ? '在线' : connectionState === 'reconnecting' ? '重连中' : '离线'}
|
||||
</span>
|
||||
{pendingCount > 0 && (
|
||||
<span className="text-xs bg-orange-100 dark:bg-orange-900/30 text-orange-600 dark:text-orange-400 px-1.5 py-0.5 rounded">
|
||||
{pendingCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default OfflineIndicator;
|
||||
@@ -1,4 +1,5 @@
|
||||
import { cn } from '../../lib/utils';
|
||||
import { MessageSquare, Inbox, Search, FileX, Wifi, Bot } from 'lucide-react';
|
||||
|
||||
interface EmptyStateProps {
|
||||
icon: React.ReactNode;
|
||||
@@ -6,19 +7,60 @@ interface EmptyStateProps {
|
||||
description: string;
|
||||
action?: React.ReactNode;
|
||||
className?: string;
|
||||
/** Size variant */
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
}
|
||||
|
||||
export function EmptyState({ icon, title, description, action, className }: EmptyStateProps) {
|
||||
export function EmptyState({
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
className,
|
||||
size = 'md'
|
||||
}: EmptyStateProps) {
|
||||
const sizeClasses = {
|
||||
sm: {
|
||||
container: 'py-4',
|
||||
iconWrapper: 'w-12 h-12',
|
||||
icon: 'w-5 h-5',
|
||||
title: 'text-sm',
|
||||
description: 'text-xs',
|
||||
},
|
||||
md: {
|
||||
container: 'p-6',
|
||||
iconWrapper: 'w-16 h-16',
|
||||
icon: 'w-8 h-8',
|
||||
title: 'text-base',
|
||||
description: 'text-sm',
|
||||
},
|
||||
lg: {
|
||||
container: 'p-8',
|
||||
iconWrapper: 'w-20 h-20',
|
||||
icon: 'w-10 h-10',
|
||||
title: 'text-lg',
|
||||
description: 'text-base',
|
||||
},
|
||||
};
|
||||
|
||||
const sizes = sizeClasses[size];
|
||||
|
||||
return (
|
||||
<div className={cn('h-full flex items-center justify-center p-6', className)}>
|
||||
<div className={cn('h-full flex items-center justify-center', sizes.container, className)}>
|
||||
<div className="text-center max-w-sm">
|
||||
<div className="w-16 h-16 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center mx-auto mb-4 text-gray-400">
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-full flex items-center justify-center mx-auto mb-4 text-gray-400',
|
||||
sizes.iconWrapper,
|
||||
'bg-gray-100 dark:bg-gray-800'
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
<h3 className="text-base font-semibold text-gray-700 dark:text-gray-300 mb-2">
|
||||
<h3 className={cn('font-semibold text-gray-700 dark:text-gray-300 mb-2', sizes.title)}>
|
||||
{title}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">
|
||||
<p className={cn('text-gray-500 dark:text-gray-400 mb-4', sizes.description)}>
|
||||
{description}
|
||||
</p>
|
||||
{action}
|
||||
@@ -26,3 +68,134 @@ export function EmptyState({ icon, title, description, action, className }: Empt
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// === Pre-built Empty State Variants ===
|
||||
|
||||
interface PrebuiltEmptyStateProps {
|
||||
action?: React.ReactNode;
|
||||
className?: string;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
}
|
||||
|
||||
/**
|
||||
* Empty state for no messages in chat.
|
||||
*/
|
||||
export function EmptyMessages({ action, className, size }: PrebuiltEmptyStateProps) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={<MessageSquare className="w-8 h-8" />}
|
||||
title="No messages yet"
|
||||
description="Start the conversation by sending a message below."
|
||||
action={action}
|
||||
className={className}
|
||||
size={size}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Empty state for no conversations.
|
||||
*/
|
||||
export function EmptyConversations({ action, className, size }: PrebuiltEmptyStateProps) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={<Inbox className="w-8 h-8" />}
|
||||
title="No conversations"
|
||||
description="Your conversation history will appear here."
|
||||
action={action}
|
||||
className={className}
|
||||
size={size}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Empty state for search with no results.
|
||||
*/
|
||||
export function EmptySearchResults({ query, action, className, size }: PrebuiltEmptyStateProps & { query?: string }) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={<Search className="w-8 h-8" />}
|
||||
title="No results found"
|
||||
description={query ? `No messages matching "${query}"` : 'Try adjusting your search terms.'}
|
||||
action={action}
|
||||
className={className}
|
||||
size={size}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Empty state for no files or attachments.
|
||||
*/
|
||||
export function EmptyFiles({ action, className, size }: PrebuiltEmptyStateProps) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={<FileX className="w-8 h-8" />}
|
||||
title="No files"
|
||||
description="No files or attachments here yet."
|
||||
action={action}
|
||||
className={className}
|
||||
size={size}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Empty state for offline/disconnected state.
|
||||
*/
|
||||
export function EmptyOffline({ action, className, size }: PrebuiltEmptyStateProps) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={<Wifi className="w-8 h-8 text-orange-400" />}
|
||||
title="Offline"
|
||||
description="Please check your connection and try again."
|
||||
action={action}
|
||||
className={className}
|
||||
size={size}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Empty state for no agents/clones available.
|
||||
*/
|
||||
export function EmptyAgents({ action, className, size }: PrebuiltEmptyStateProps) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={<Bot className="w-8 h-8" />}
|
||||
title="No agents"
|
||||
description="Create an agent to get started with personalized conversations."
|
||||
action={action}
|
||||
className={className}
|
||||
size={size}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Empty state for welcome screen.
|
||||
*/
|
||||
export function WelcomeEmptyState({
|
||||
title = "Welcome to ZCLAW",
|
||||
description = "Send a message to start the conversation.",
|
||||
connected = true,
|
||||
action,
|
||||
className,
|
||||
size,
|
||||
}: PrebuiltEmptyStateProps & {
|
||||
title?: string;
|
||||
description?: string;
|
||||
connected?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={<MessageSquare className="w-8 h-8" />}
|
||||
title={title}
|
||||
description={connected ? description : 'Please connect to Gateway first.'}
|
||||
action={action}
|
||||
className={className}
|
||||
size={size}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -109,15 +109,15 @@ const CATEGORY_CONFIG: Record<ErrorCategory, {
|
||||
/**
|
||||
* Get icon component for error category
|
||||
*/
|
||||
export function getIconByCategory(category: ErrorCategory) typeof Wifi | typeof Shield | typeof Clock | typeof Settings | typeof AlertCircle | typeof AlertTriangle {
|
||||
return CATEGORY_CONFIG[category]?. CATEGORY_CONFIG[category].icon : AlertCircle;
|
||||
export function getIconByCategory(category: ErrorCategory): typeof Wifi | typeof Shield | typeof Clock | typeof Settings | typeof AlertCircle | typeof AlertTriangle {
|
||||
return CATEGORY_CONFIG[category]?.icon ?? AlertCircle;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get color class for error category
|
||||
*/
|
||||
export function getColorByCategory(category: ErrorCategory) string {
|
||||
return CATEGORY_CONFIG[category]?. CATEGORY_CONFIG[category].color : 'text-gray-500';
|
||||
export function getColorByCategory(category: ErrorCategory): string {
|
||||
return CATEGORY_CONFIG[category]?.color ?? 'text-gray-500';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -140,11 +140,11 @@ export function ErrorAlert({
|
||||
});
|
||||
|
||||
// Normalize error input
|
||||
const appError = typeof error === 'string'
|
||||
? classifyError(new Error(error))
|
||||
: error instanceof Error
|
||||
? classifyError(error)
|
||||
: error;
|
||||
const appError = typeof errorProp === 'string'
|
||||
? classifyError(new Error(errorProp))
|
||||
: errorProp instanceof Error
|
||||
? classifyError(errorProp)
|
||||
: errorProp;
|
||||
|
||||
const {
|
||||
category,
|
||||
|
||||
@@ -1,66 +1,210 @@
|
||||
import { Component, ReactNode, ErrorInfo } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { AlertTriangle, RefreshCcw, Bug, Home } from 'lucide-react';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { Component, ReactNode, ErrorInfo as ReactErrorInfo } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { AlertTriangle, RefreshCcw, Bug, Home, WifiOff } from 'lucide-react';
|
||||
import { Button } from './Button';
|
||||
import { reportError } from '../../lib/error-handling';
|
||||
import { classifyError, AppError } from '../../lib/error-types';
|
||||
|
||||
// === Types ===
|
||||
|
||||
/** Extended error info with additional metadata */
|
||||
interface ExtendedErrorInfo extends ReactErrorInfo {
|
||||
errorName?: string;
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
interface ErrorBoundaryProps {
|
||||
children: ReactNode;
|
||||
fallback?: ReactNode;
|
||||
onError?: (error: Error, errorInfo: ErrorInfo) => void;
|
||||
onError?: (error: Error, errorInfo: ReactErrorInfo) => void;
|
||||
onReset?: () => void;
|
||||
/** Whether to show connection status indicator */
|
||||
showConnectionStatus?: boolean;
|
||||
/** Custom error title */
|
||||
errorTitle?: string;
|
||||
/** Custom error message */
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
interface ErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
errorInfo: ErrorInfo | null;
|
||||
errorInfo: ExtendedErrorInfo | null;
|
||||
appError: AppError | null;
|
||||
showDetails: boolean;
|
||||
}
|
||||
|
||||
// === Global Error Types ===
|
||||
|
||||
type GlobalErrorType = 'unhandled-rejection' | 'error' | 'websocket' | 'network';
|
||||
|
||||
interface GlobalErrorEvent {
|
||||
type: GlobalErrorType;
|
||||
error: unknown;
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
// === Global Error Handler Registry ===
|
||||
|
||||
const globalErrorListeners = new Set<(event: GlobalErrorEvent) => void>();
|
||||
|
||||
export function addGlobalErrorListener(listener: (event: GlobalErrorEvent) => void): () => void {
|
||||
globalErrorListeners.add(listener);
|
||||
return () => globalErrorListeners.delete(listener);
|
||||
}
|
||||
|
||||
function notifyGlobalErrorListeners(event: GlobalErrorEvent): void {
|
||||
globalErrorListeners.forEach(listener => {
|
||||
try {
|
||||
listener(event);
|
||||
} catch (e) {
|
||||
console.error('[GlobalErrorHandler] Listener error:', e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// === Setup Global Error Handlers ===
|
||||
|
||||
let globalHandlersSetup = false;
|
||||
|
||||
export function setupGlobalErrorHandlers(): () => void {
|
||||
if (globalHandlersSetup) {
|
||||
return () => {};
|
||||
}
|
||||
globalHandlersSetup = true;
|
||||
|
||||
// Handle unhandled promise rejections
|
||||
const handleRejection = (event: PromiseRejectionEvent) => {
|
||||
console.error('[GlobalErrorHandler] Unhandled rejection:', event.reason);
|
||||
notifyGlobalErrorListeners({
|
||||
type: 'unhandled-rejection',
|
||||
error: event.reason,
|
||||
timestamp: new Date(),
|
||||
});
|
||||
// Prevent default browser error logging (we handle it ourselves)
|
||||
event.preventDefault();
|
||||
};
|
||||
|
||||
// Handle uncaught errors
|
||||
const handleError = (event: ErrorEvent) => {
|
||||
console.error('[GlobalErrorHandler] Uncaught error:', event.error);
|
||||
notifyGlobalErrorListeners({
|
||||
type: 'error',
|
||||
error: event.error,
|
||||
timestamp: new Date(),
|
||||
});
|
||||
// Let the error boundary handle it if possible
|
||||
};
|
||||
|
||||
// Handle WebSocket errors globally
|
||||
const handleWebSocketError = (event: Event) => {
|
||||
if (event.target instanceof WebSocket) {
|
||||
console.error('[GlobalErrorHandler] WebSocket error:', event);
|
||||
notifyGlobalErrorListeners({
|
||||
type: 'websocket',
|
||||
error: new Error('WebSocket connection error'),
|
||||
timestamp: new Date(),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('unhandledrejection', handleRejection);
|
||||
window.addEventListener('error', handleError);
|
||||
window.addEventListener('error', handleWebSocketError, true); // Capture phase for WebSocket
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('unhandledrejection', handleRejection);
|
||||
window.removeEventListener('error', handleError);
|
||||
window.removeEventListener('error', handleWebSocketError, true);
|
||||
globalHandlersSetup = false;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* ErrorBoundary Component
|
||||
* GlobalErrorBoundary Component
|
||||
*
|
||||
* Catches React rendering errors and displays a friendly error screen
|
||||
* with recovery options and error reporting.
|
||||
* Root-level error boundary that catches all React errors and global errors.
|
||||
* Displays a user-friendly error screen with recovery options.
|
||||
*/
|
||||
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
||||
export class GlobalErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
||||
private cleanupGlobalHandlers: (() => void) | null = null;
|
||||
|
||||
constructor(props: ErrorBoundaryProps) {
|
||||
super(props);
|
||||
this.state = {
|
||||
hasError: false,
|
||||
error: null,
|
||||
errorInfo: null,
|
||||
appError: null,
|
||||
showDetails: false,
|
||||
};
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): ErrorInfo {
|
||||
static getDerivedStateFromError(error: Error): Partial<ErrorBoundaryState> {
|
||||
const appError = classifyError(error);
|
||||
return {
|
||||
componentStack: error.stack || 'No stack trace available',
|
||||
hasError: true,
|
||||
error,
|
||||
appError,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
// Setup global error handlers
|
||||
this.cleanupGlobalHandlers = setupGlobalErrorHandlers();
|
||||
|
||||
// Listen for global errors and update state
|
||||
const unsubscribe = addGlobalErrorListener((event) => {
|
||||
if (!this.state.hasError) {
|
||||
const appError = classifyError(event.error);
|
||||
this.setState({
|
||||
hasError: true,
|
||||
error: event.error instanceof Error ? event.error : new Error(String(event.error)),
|
||||
appError,
|
||||
errorInfo: null,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Store cleanup function
|
||||
this.cleanupGlobalHandlers = () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.cleanupGlobalHandlers?.();
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: ReactErrorInfo) {
|
||||
const { onError } = this.props;
|
||||
|
||||
// Classify the error
|
||||
const appError = classifyError(error);
|
||||
|
||||
// Update state with extended error info
|
||||
const extendedErrorInfo: ExtendedErrorInfo = {
|
||||
componentStack: errorInfo.componentStack,
|
||||
errorName: error.name || 'Unknown Error',
|
||||
errorMessage: error.message || 'An unexpected error occurred',
|
||||
};
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
const { onError } = this.props;
|
||||
this.setState({
|
||||
errorInfo: extendedErrorInfo,
|
||||
appError,
|
||||
});
|
||||
|
||||
// Call optional error handler
|
||||
if (onError) {
|
||||
onError(error, errorInfo);
|
||||
}
|
||||
|
||||
// Update state to show error UI
|
||||
this.setState({
|
||||
hasError: true,
|
||||
error,
|
||||
errorInfo: {
|
||||
componentStack: errorInfo.componentStack,
|
||||
errorName: errorInfo.errorName || error.name || 'Unknown Error',
|
||||
errorMessage: errorInfo.errorMessage || error.message || 'An unexpected error occurred',
|
||||
},
|
||||
// Report to error tracking
|
||||
reportError(error, {
|
||||
componentStack: errorInfo.componentStack ?? undefined,
|
||||
errorName: error.name,
|
||||
errorMessage: error.message,
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
handleReset = () => {
|
||||
@@ -71,6 +215,8 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt
|
||||
hasError: false,
|
||||
error: null,
|
||||
errorInfo: null,
|
||||
appError: null,
|
||||
showDetails: false,
|
||||
});
|
||||
|
||||
// Call optional reset handler
|
||||
@@ -79,25 +225,34 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt
|
||||
}
|
||||
};
|
||||
|
||||
handleReport = () => {
|
||||
const { error, errorInfo } = this.state;
|
||||
if (error && errorInfo) {
|
||||
reportError(error, {
|
||||
componentStack: errorInfo.componentStack,
|
||||
errorName: errorInfo.errorName,
|
||||
errorMessage: errorInfo.errorMessage,
|
||||
});
|
||||
}
|
||||
handleReload = () => {
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
handleGoHome = () => {
|
||||
// Navigate to home/main view
|
||||
window.location.href = '/';
|
||||
};
|
||||
|
||||
handleReport = () => {
|
||||
const { error, errorInfo } = this.state;
|
||||
if (error) {
|
||||
reportError(error, {
|
||||
componentStack: errorInfo?.componentStack ?? undefined,
|
||||
errorName: errorInfo?.errorName || error.name,
|
||||
errorMessage: errorInfo?.errorMessage || error.message,
|
||||
});
|
||||
// Show confirmation
|
||||
alert('Error reported. Thank you for your feedback.');
|
||||
}
|
||||
};
|
||||
|
||||
toggleDetails = () => {
|
||||
this.setState(prev => ({ showDetails: !prev.showDetails }));
|
||||
};
|
||||
|
||||
render() {
|
||||
const { children, fallback } = this.props;
|
||||
const { hasError, error, errorInfo } = this.state;
|
||||
const { children, fallback, errorTitle, errorMessage } = this.props;
|
||||
const { hasError, error, errorInfo, appError, showDetails } = this.state;
|
||||
|
||||
if (hasError && error) {
|
||||
// Use custom fallback if provided
|
||||
@@ -105,47 +260,129 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt
|
||||
return fallback;
|
||||
}
|
||||
|
||||
// Default error UI
|
||||
// Get error display info
|
||||
const title = errorTitle || appError?.title || 'Something went wrong';
|
||||
const message = errorMessage || appError?.message || error.message || 'An unexpected error occurred';
|
||||
const category = appError?.category || 'system';
|
||||
const isNetworkError = category === 'network';
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 p-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="max-w-md w-full bg-white dark:bg-gray-800 rounded-xl shadow-lg overflow-hidden"
|
||||
transition={{ duration: 0.2 }}
|
||||
className="max-w-lg w-full bg-white dark:bg-gray-800 rounded-xl shadow-lg overflow-hidden"
|
||||
>
|
||||
{/* Error Icon */}
|
||||
<div className="flex items-center justify-center w-16 h-16 bg-red-100 dark:bg-red-900/20 rounded-full mx-4">
|
||||
<AlertTriangle className="w-8 h-8 text-red-500" />
|
||||
{/* Error Header */}
|
||||
<div className={`p-6 ${isNetworkError ? 'bg-orange-50 dark:bg-orange-900/20' : 'bg-red-50 dark:bg-red-900/20'}`}>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className={`p-3 rounded-full ${isNetworkError ? 'bg-orange-100 dark:bg-orange-900/40' : 'bg-red-100 dark:bg-red-900/40'}`}>
|
||||
{isNetworkError ? (
|
||||
<WifiOff className="w-8 h-8 text-orange-500" />
|
||||
) : (
|
||||
<AlertTriangle className="w-8 h-8 text-red-500" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{title}
|
||||
</h2>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
{message}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6 text-center">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
|
||||
Something went wrong
|
||||
</h2>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||
{errorInfo?.errorMessage || error.message || 'An unexpected error occurred'}
|
||||
</p>
|
||||
{/* Error Details */}
|
||||
<div className="p-6">
|
||||
{/* Category Badge */}
|
||||
{appError && (
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<span className={`px-2 py-1 text-xs font-medium rounded-full ${
|
||||
category === 'network' ? 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400' :
|
||||
category === 'auth' ? 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400' :
|
||||
category === 'server' ? 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400' :
|
||||
'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300'
|
||||
}`}>
|
||||
{category.charAt(0).toUpperCase() + category.slice(1)} Error
|
||||
</span>
|
||||
{appError.recoverable && (
|
||||
<span className="px-2 py-1 text-xs font-medium rounded-full bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400">
|
||||
Recoverable
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error Details */}
|
||||
<div className="mt-4 p-4 bg-gray-50 dark:bg-gray-700 rounded-lg text-left">
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 font-mono">
|
||||
{errorInfo?.errorName || 'Unknown Error'}
|
||||
</p>
|
||||
</div>
|
||||
{/* Recovery Steps */}
|
||||
{appError?.recoverySteps && appError.recoverySteps.length > 0 && (
|
||||
<div className="mb-4 p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Suggested Actions:
|
||||
</h3>
|
||||
<ul className="space-y-2">
|
||||
{appError.recoverySteps.slice(0, 3).map((step, index) => (
|
||||
<li key={index} className="text-sm text-gray-600 dark:text-gray-400 flex items-start gap-2">
|
||||
<span className="text-gray-400 mt-0.5">{index + 1}.</span>
|
||||
<span>{step.description}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Technical Details Toggle */}
|
||||
<button
|
||||
onClick={this.toggleDetails}
|
||||
className="text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 flex items-center gap-1 mb-4"
|
||||
>
|
||||
<span>{showDetails ? 'Hide' : 'Show'} technical details</span>
|
||||
<motion.span
|
||||
animate={{ rotate: showDetails ? 180 : 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
▼
|
||||
</motion.span>
|
||||
</button>
|
||||
|
||||
{/* Technical Details */}
|
||||
{showDetails && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
className="overflow-hidden mb-4"
|
||||
>
|
||||
<pre className="p-3 bg-gray-100 dark:bg-gray-700 rounded-lg text-xs text-gray-600 dark:text-gray-400 overflow-x-auto whitespace-pre-wrap break-words max-h-48">
|
||||
{errorInfo?.errorName || error.name}: {errorInfo?.errorMessage || error.message}
|
||||
{errorInfo?.componentStack && `\n\nComponent Stack:${errorInfo.componentStack}`}
|
||||
</pre>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex flex-col gap-2 mt-6">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={this.handleReset}
|
||||
className="w-full"
|
||||
>
|
||||
<RefreshC className="w-4 h-4 mr-2" />
|
||||
Try Again
|
||||
</Button>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={this.handleReset}
|
||||
className="flex-1"
|
||||
>
|
||||
<RefreshCcw className="w-4 h-4 mr-2" />
|
||||
Try Again
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={this.handleReload}
|
||||
className="flex-1"
|
||||
>
|
||||
Reload Page
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -156,7 +393,6 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt
|
||||
<Bug className="w-4 h-4 mr-2" />
|
||||
Report Issue
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -168,12 +404,123 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return children;
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ErrorBoundary Component
|
||||
*
|
||||
* A simpler error boundary for wrapping individual components or sections.
|
||||
* Use GlobalErrorBoundary for the root level.
|
||||
*/
|
||||
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
||||
constructor(props: ErrorBoundaryProps) {
|
||||
super(props);
|
||||
this.state = {
|
||||
hasError: false,
|
||||
error: null,
|
||||
errorInfo: null,
|
||||
appError: null,
|
||||
showDetails: false,
|
||||
};
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): Partial<ErrorBoundaryState> {
|
||||
const appError = classifyError(error);
|
||||
return {
|
||||
hasError: true,
|
||||
error,
|
||||
appError,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: ReactErrorInfo) {
|
||||
const { onError } = this.props;
|
||||
|
||||
// Update state with extended error info
|
||||
const extendedErrorInfo: ExtendedErrorInfo = {
|
||||
componentStack: errorInfo.componentStack,
|
||||
errorName: error.name || 'Unknown Error',
|
||||
errorMessage: error.message || 'An unexpected error occurred',
|
||||
};
|
||||
|
||||
this.setState({
|
||||
errorInfo: extendedErrorInfo,
|
||||
});
|
||||
|
||||
// Call optional error handler
|
||||
if (onError) {
|
||||
onError(error, errorInfo);
|
||||
}
|
||||
|
||||
// Report error
|
||||
reportError(error, {
|
||||
componentStack: errorInfo.componentStack ?? undefined,
|
||||
errorName: error.name,
|
||||
errorMessage: error.message,
|
||||
});
|
||||
}
|
||||
|
||||
handleReset = () => {
|
||||
const { onReset } = this.props;
|
||||
this.setState({
|
||||
hasError: false,
|
||||
error: null,
|
||||
errorInfo: null,
|
||||
appError: null,
|
||||
showDetails: false,
|
||||
});
|
||||
if (onReset) {
|
||||
onReset();
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const { children, fallback } = this.props;
|
||||
const { hasError, error, appError } = this.state;
|
||||
|
||||
if (hasError && error) {
|
||||
if (fallback) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
// Compact error UI for nested boundaries
|
||||
return (
|
||||
<div className="p-4 bg-red-50 dark:bg-red-900/20 rounded-lg border border-red-200 dark:border-red-800">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="w-5 h-5 text-red-500 flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-sm font-medium text-red-800 dark:text-red-200">
|
||||
{appError?.title || 'Error'}
|
||||
</h3>
|
||||
<p className="text-sm text-red-600 dark:text-red-400 mt-1">
|
||||
{appError?.message || error.message}
|
||||
</p>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={this.handleReset}
|
||||
className="mt-2 text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-200"
|
||||
>
|
||||
<RefreshCcw className="w-3 h-3 mr-1" />
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
}
|
||||
|
||||
// === Re-export for convenience ===
|
||||
export { GlobalErrorBoundary as RootErrorBoundary };
|
||||
|
||||
106
desktop/src/components/ui/LoadingSpinner.tsx
Normal file
106
desktop/src/components/ui/LoadingSpinner.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import { cn } from '../../lib/utils';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
interface LoadingSpinnerProps {
|
||||
/** Size of the spinner */
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
/** Optional text to display below the spinner */
|
||||
text?: string;
|
||||
/** Additional class names */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'w-4 h-4',
|
||||
md: 'w-6 h-6',
|
||||
lg: 'w-8 h-8',
|
||||
};
|
||||
|
||||
/**
|
||||
* Small inline loading spinner for buttons and inline contexts.
|
||||
*/
|
||||
export function LoadingSpinner({ size = 'md', text, className }: LoadingSpinnerProps) {
|
||||
return (
|
||||
<div className={cn('flex items-center gap-2', className)}>
|
||||
<Loader2 className={cn('animate-spin text-gray-400 dark:text-gray-500', sizeClasses[size])} />
|
||||
{text && <span className="text-sm text-gray-500 dark:text-gray-400">{text}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface LoadingOverlayProps {
|
||||
/** Whether the overlay is visible */
|
||||
visible: boolean;
|
||||
/** Optional text to display */
|
||||
text?: string;
|
||||
/** Additional class names */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full-screen loading overlay for blocking interactions during loading.
|
||||
*/
|
||||
export function LoadingOverlay({ visible, text = 'Loading...', className }: LoadingOverlayProps) {
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute inset-0 bg-white/80 dark:bg-gray-900/80 backdrop-blur-sm',
|
||||
'flex items-center justify-center z-50',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-orange-500" />
|
||||
<span className="text-sm text-gray-600 dark:text-gray-300">{text}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface LoadingDotsProps {
|
||||
/** Additional class names */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Animated dots for "thinking" states.
|
||||
*/
|
||||
export function LoadingDots({ className }: LoadingDotsProps) {
|
||||
return (
|
||||
<div className={cn('flex items-center gap-1', className)}>
|
||||
<span
|
||||
className="w-2 h-2 bg-gray-400 dark:bg-gray-500 rounded-full animate-bounce"
|
||||
style={{ animationDelay: '0ms' }}
|
||||
/>
|
||||
<span
|
||||
className="w-2 h-2 bg-gray-400 dark:bg-gray-500 rounded-full animate-bounce"
|
||||
style={{ animationDelay: '150ms' }}
|
||||
/>
|
||||
<span
|
||||
className="w-2 h-2 bg-gray-400 dark:bg-gray-500 rounded-full animate-bounce"
|
||||
style={{ animationDelay: '300ms' }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface InlineLoadingProps {
|
||||
/** Loading text */
|
||||
text?: string;
|
||||
/** Additional class names */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact inline loading indicator with text.
|
||||
*/
|
||||
export function InlineLoading({ text = 'Loading...', className }: InlineLoadingProps) {
|
||||
return (
|
||||
<div className={cn('flex items-center gap-2 px-4 py-3 text-gray-500 dark:text-gray-400', className)}>
|
||||
<LoadingDots />
|
||||
<span className="text-sm">{text}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -40,3 +40,142 @@ export function ListSkeleton({ count = 3 }: { count?: number }) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Skeleton for a single chat message bubble.
|
||||
* Supports both user and assistant message styles.
|
||||
*/
|
||||
export function MessageSkeleton({ isUser = false }: { isUser?: boolean }) {
|
||||
return (
|
||||
<div className={cn('flex gap-4', isUser && 'justify-end')}>
|
||||
<div
|
||||
className={cn(
|
||||
'w-8 h-8 rounded-lg flex-shrink-0',
|
||||
isUser ? 'bg-gray-200 dark:bg-gray-600 order-last' : 'bg-gray-300 dark:bg-gray-600'
|
||||
)}
|
||||
>
|
||||
<Skeleton className="w-full h-full rounded-lg" />
|
||||
</div>
|
||||
<div className={cn('flex-1', isUser && 'max-w-2xl')}>
|
||||
<div
|
||||
className={cn(
|
||||
'p-4 rounded-2xl',
|
||||
isUser
|
||||
? 'bg-orange-100 dark:bg-orange-900/30'
|
||||
: 'bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700'
|
||||
)}
|
||||
>
|
||||
<Skeleton className="h-4 w-full mb-2" />
|
||||
<Skeleton className="h-4 w-3/4 mb-2" />
|
||||
<Skeleton className="h-4 w-1/2" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Skeleton for a list of chat messages.
|
||||
* Alternates between user and assistant skeletons.
|
||||
*/
|
||||
export function MessageListSkeleton({ count = 4 }: { count?: number }) {
|
||||
return (
|
||||
<div className="space-y-6 p-6">
|
||||
{Array.from({ length: count }).map((_, i) => (
|
||||
<MessageSkeleton key={i} isUser={i % 2 === 0} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Skeleton for a conversation item in the sidebar.
|
||||
*/
|
||||
export function ConversationItemSkeleton() {
|
||||
return (
|
||||
<div className="flex items-center gap-3 px-3 py-3 border-b border-gray-50 dark:border-gray-800">
|
||||
<Skeleton className="w-7 h-7 rounded-lg flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<Skeleton className="h-3 w-24 mb-1.5" />
|
||||
<Skeleton className="h-2 w-32" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Skeleton for the conversation list sidebar.
|
||||
*/
|
||||
export function ConversationListSkeleton({ count = 5 }: { count?: number }) {
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header skeleton */}
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b border-gray-200 dark:border-gray-700">
|
||||
<Skeleton className="h-3 w-16" />
|
||||
<Skeleton className="w-4 h-4 rounded" />
|
||||
</div>
|
||||
{/* List items */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{Array.from({ length: count }).map((_, i) => (
|
||||
<ConversationItemSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Skeleton for the chat header.
|
||||
*/
|
||||
export function ChatHeaderSkeleton() {
|
||||
return (
|
||||
<div className="h-14 border-b border-gray-100 dark:border-gray-800 flex items-center justify-between px-6 bg-white dark:bg-gray-900">
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-5 w-24" />
|
||||
<Skeleton className="h-3 w-20" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-8 w-8 rounded-full" />
|
||||
<Skeleton className="h-8 w-8 rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Skeleton for the chat input area.
|
||||
*/
|
||||
export function ChatInputSkeleton() {
|
||||
return (
|
||||
<div className="border-t border-gray-100 dark:border-gray-800 p-4 bg-white dark:bg-gray-900">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="flex items-end gap-2 bg-gray-50 dark:bg-gray-800 rounded-2xl border border-gray-200 dark:border-gray-700 p-2">
|
||||
<Skeleton className="w-5 h-5 rounded" />
|
||||
<div className="flex-1 py-1">
|
||||
<Skeleton className="h-5 w-full" />
|
||||
</div>
|
||||
<Skeleton className="w-16 h-6 rounded" />
|
||||
<Skeleton className="w-8 h-8 rounded-full" />
|
||||
</div>
|
||||
<div className="text-center mt-2">
|
||||
<Skeleton className="h-3 w-40 mx-auto" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Full chat area skeleton including header, messages, and input.
|
||||
*/
|
||||
export function ChatAreaSkeleton({ messageCount = 4 }: { messageCount?: number }) {
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<ChatHeaderSkeleton />
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<MessageListSkeleton count={messageCount} />
|
||||
</div>
|
||||
<ChatInputSkeleton />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,8 +8,38 @@ export type { InputProps } from './Input';
|
||||
|
||||
export { Badge } from './Badge';
|
||||
|
||||
export { Skeleton, CardSkeleton, ListSkeleton } from './Skeleton';
|
||||
// Skeleton components
|
||||
export {
|
||||
Skeleton,
|
||||
CardSkeleton,
|
||||
ListSkeleton,
|
||||
MessageSkeleton,
|
||||
MessageListSkeleton,
|
||||
ConversationItemSkeleton,
|
||||
ConversationListSkeleton,
|
||||
ChatHeaderSkeleton,
|
||||
ChatInputSkeleton,
|
||||
ChatAreaSkeleton,
|
||||
} from './Skeleton';
|
||||
|
||||
export { EmptyState } from './EmptyState';
|
||||
// Empty state components
|
||||
export {
|
||||
EmptyState,
|
||||
EmptyMessages,
|
||||
EmptyConversations,
|
||||
EmptySearchResults,
|
||||
EmptyFiles,
|
||||
EmptyOffline,
|
||||
EmptyAgents,
|
||||
WelcomeEmptyState,
|
||||
} from './EmptyState';
|
||||
|
||||
// Loading components
|
||||
export {
|
||||
LoadingSpinner,
|
||||
LoadingOverlay,
|
||||
LoadingDots,
|
||||
InlineLoading,
|
||||
} from './LoadingSpinner';
|
||||
|
||||
export { ToastProvider, useToast } from './Toast';
|
||||
|
||||
476
desktop/src/lib/api-key-storage.ts
Normal file
476
desktop/src/lib/api-key-storage.ts
Normal file
@@ -0,0 +1,476 @@
|
||||
/**
|
||||
* Secure API Key Storage
|
||||
*
|
||||
* Provides secure storage for API keys and sensitive credentials.
|
||||
* Uses OS keychain when available, with encrypted localStorage fallback.
|
||||
*
|
||||
* Security features:
|
||||
* - Keys stored in OS keychain (Windows DPAPI, macOS Keychain, Linux Secret Service)
|
||||
* - Encrypted backup in localStorage for migration support
|
||||
* - Key validation and format checking
|
||||
* - Audit logging for key access
|
||||
* - Support for multiple API key types
|
||||
*/
|
||||
|
||||
import { secureStorage, isSecureStorageAvailable } from './secure-storage';
|
||||
import { hashSha256 } from './crypto-utils';
|
||||
|
||||
// Storage key prefixes
|
||||
const API_KEY_PREFIX = 'zclaw_api_key_';
|
||||
const API_KEY_META_PREFIX = 'zclaw_api_key_meta_';
|
||||
|
||||
/**
|
||||
* Supported API key types
|
||||
*/
|
||||
export type ApiKeyType =
|
||||
| 'openai'
|
||||
| 'anthropic'
|
||||
| 'google'
|
||||
| 'deepseek'
|
||||
| 'zhipu'
|
||||
| 'moonshot'
|
||||
| 'custom';
|
||||
|
||||
/**
|
||||
* API key metadata
|
||||
*/
|
||||
export interface ApiKeyMetadata {
|
||||
type: ApiKeyType;
|
||||
name: string;
|
||||
description?: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
lastUsedAt?: number;
|
||||
keyHash: string; // Partial hash for validation
|
||||
prefix: string; // First 8 characters for display
|
||||
isValid?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* API key entry with metadata
|
||||
*/
|
||||
export interface ApiKeyEntry {
|
||||
key: string;
|
||||
metadata: ApiKeyMetadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validation rules for different API key types
|
||||
*/
|
||||
const KEY_VALIDATION_RULES: Record<ApiKeyType, {
|
||||
pattern: RegExp;
|
||||
minLength: number;
|
||||
maxLength: number;
|
||||
prefix?: string[];
|
||||
}> = {
|
||||
openai: {
|
||||
pattern: /^sk-[A-Za-z0-9_-]{20,}$/,
|
||||
minLength: 20,
|
||||
maxLength: 200,
|
||||
prefix: ['sk-'],
|
||||
},
|
||||
anthropic: {
|
||||
pattern: /^sk-ant-[A-Za-z0-9_-]{20,}$/,
|
||||
minLength: 20,
|
||||
maxLength: 200,
|
||||
prefix: ['sk-ant-'],
|
||||
},
|
||||
google: {
|
||||
pattern: /^AIza[A-Za-z0-9_-]{35}$/,
|
||||
minLength: 35,
|
||||
maxLength: 50,
|
||||
prefix: ['AIza'],
|
||||
},
|
||||
deepseek: {
|
||||
pattern: /^sk-[A-Za-z0-9]{20,}$/,
|
||||
minLength: 20,
|
||||
maxLength: 100,
|
||||
prefix: ['sk-'],
|
||||
},
|
||||
zhipu: {
|
||||
pattern: /^[A-Za-z0-9_.-]{20,}$/,
|
||||
minLength: 20,
|
||||
maxLength: 100,
|
||||
},
|
||||
moonshot: {
|
||||
pattern: /^sk-[A-Za-z0-9]{20,}$/,
|
||||
minLength: 20,
|
||||
maxLength: 100,
|
||||
prefix: ['sk-'],
|
||||
},
|
||||
custom: {
|
||||
pattern: /^.{8,}$/,
|
||||
minLength: 8,
|
||||
maxLength: 500,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Validate an API key format
|
||||
*
|
||||
* @param type - The API key type
|
||||
* @param key - The API key to validate
|
||||
* @returns True if the key format is valid
|
||||
*/
|
||||
export function validateApiKeyFormat(type: ApiKeyType, key: string): {
|
||||
valid: boolean;
|
||||
error?: string;
|
||||
} {
|
||||
const rules = KEY_VALIDATION_RULES[type];
|
||||
|
||||
if (!key || typeof key !== 'string') {
|
||||
return { valid: false, error: 'API key is required' };
|
||||
}
|
||||
|
||||
// Trim whitespace
|
||||
const trimmedKey = key.trim();
|
||||
|
||||
if (trimmedKey.length < rules.minLength) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `API key too short (minimum ${rules.minLength} characters)`,
|
||||
};
|
||||
}
|
||||
|
||||
if (trimmedKey.length > rules.maxLength) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `API key too long (maximum ${rules.maxLength} characters)`,
|
||||
};
|
||||
}
|
||||
|
||||
if (!rules.pattern.test(trimmedKey)) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Invalid API key format for type: ${type}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (rules.prefix && !rules.prefix.some(p => trimmedKey.startsWith(p))) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `API key must start with: ${rules.prefix.join(' or ')}`,
|
||||
};
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a partial hash for key validation
|
||||
* Uses first 8 characters for identification without exposing full key
|
||||
*/
|
||||
async function createKeyHash(key: string): Promise<string> {
|
||||
// Use partial hash for validation
|
||||
const partialKey = key.slice(0, 8) + key.slice(-4);
|
||||
return hashSha256(partialKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Store an API key securely
|
||||
*
|
||||
* @param type - The API key type
|
||||
* @param key - The API key value
|
||||
* @param options - Optional metadata
|
||||
*/
|
||||
export async function storeApiKey(
|
||||
type: ApiKeyType,
|
||||
key: string,
|
||||
options?: {
|
||||
name?: string;
|
||||
description?: string;
|
||||
}
|
||||
): Promise<ApiKeyMetadata> {
|
||||
// Validate key format
|
||||
const validation = validateApiKeyFormat(type, key);
|
||||
if (!validation.valid) {
|
||||
throw new Error(validation.error);
|
||||
}
|
||||
|
||||
const trimmedKey = key.trim();
|
||||
const now = Date.now();
|
||||
const keyHash = await createKeyHash(trimmedKey);
|
||||
|
||||
const metadata: ApiKeyMetadata = {
|
||||
type,
|
||||
name: options?.name || `${type}_api_key`,
|
||||
description: options?.description,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
keyHash,
|
||||
prefix: trimmedKey.slice(0, 8) + '...',
|
||||
isValid: true,
|
||||
};
|
||||
|
||||
// Store key in secure storage
|
||||
const storageKey = API_KEY_PREFIX + type;
|
||||
await secureStorage.set(storageKey, trimmedKey);
|
||||
|
||||
// Store metadata in localStorage (non-sensitive)
|
||||
localStorage.setItem(
|
||||
API_KEY_META_PREFIX + type,
|
||||
JSON.stringify(metadata)
|
||||
);
|
||||
|
||||
// Log security event
|
||||
logSecurityEvent('api_key_stored', { type, prefix: metadata.prefix });
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve an API key from secure storage
|
||||
*
|
||||
* @param type - The API key type
|
||||
* @returns The API key or null if not found
|
||||
*/
|
||||
export async function getApiKey(type: ApiKeyType): Promise<string | null> {
|
||||
const storageKey = API_KEY_PREFIX + type;
|
||||
const key = await secureStorage.get(storageKey);
|
||||
|
||||
if (!key) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Validate key still matches stored hash
|
||||
const metaJson = localStorage.getItem(API_KEY_META_PREFIX + type);
|
||||
if (metaJson) {
|
||||
try {
|
||||
const metadata: ApiKeyMetadata = JSON.parse(metaJson);
|
||||
const currentHash = await createKeyHash(key);
|
||||
|
||||
if (currentHash !== metadata.keyHash) {
|
||||
console.error('[ApiKeyStorage] Key hash mismatch - possible tampering');
|
||||
logSecurityEvent('api_key_hash_mismatch', { type });
|
||||
return null;
|
||||
}
|
||||
|
||||
// Update last used timestamp
|
||||
metadata.lastUsedAt = Date.now();
|
||||
localStorage.setItem(API_KEY_META_PREFIX + type, JSON.stringify(metadata));
|
||||
} catch {
|
||||
// Ignore metadata parsing errors
|
||||
}
|
||||
}
|
||||
|
||||
logSecurityEvent('api_key_accessed', { type });
|
||||
return key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get API key metadata (without the actual key)
|
||||
*
|
||||
* @param type - The API key type
|
||||
* @returns The metadata or null if not found
|
||||
*/
|
||||
export function getApiKeyMetadata(type: ApiKeyType): ApiKeyMetadata | null {
|
||||
const metaJson = localStorage.getItem(API_KEY_META_PREFIX + type);
|
||||
if (!metaJson) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(metaJson) as ApiKeyMetadata;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List all stored API key metadata
|
||||
*
|
||||
* @returns Array of API key metadata
|
||||
*/
|
||||
export function listApiKeyMetadata(): ApiKeyMetadata[] {
|
||||
const metadata: ApiKeyMetadata[] = [];
|
||||
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const key = localStorage.key(i);
|
||||
if (key?.startsWith(API_KEY_META_PREFIX)) {
|
||||
try {
|
||||
const meta = JSON.parse(localStorage.getItem(key) || '');
|
||||
metadata.push(meta);
|
||||
} catch {
|
||||
// Ignore parsing errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an API key
|
||||
*
|
||||
* @param type - The API key type
|
||||
*/
|
||||
export async function deleteApiKey(type: ApiKeyType): Promise<void> {
|
||||
const storageKey = API_KEY_PREFIX + type;
|
||||
await secureStorage.delete(storageKey);
|
||||
localStorage.removeItem(API_KEY_META_PREFIX + type);
|
||||
|
||||
logSecurityEvent('api_key_deleted', { type });
|
||||
}
|
||||
|
||||
/**
|
||||
* Update API key metadata
|
||||
*
|
||||
* @param type - The API key type
|
||||
* @param updates - Metadata updates
|
||||
*/
|
||||
export function updateApiKeyMetadata(
|
||||
type: ApiKeyType,
|
||||
updates: Partial<Pick<ApiKeyMetadata, 'name' | 'description'>>
|
||||
): void {
|
||||
const metaJson = localStorage.getItem(API_KEY_META_PREFIX + type);
|
||||
if (!metaJson) {
|
||||
throw new Error(`API key metadata not found for type: ${type}`);
|
||||
}
|
||||
|
||||
const metadata: ApiKeyMetadata = JSON.parse(metaJson);
|
||||
Object.assign(metadata, updates, { updatedAt: Date.now() });
|
||||
|
||||
localStorage.setItem(API_KEY_META_PREFIX + type, JSON.stringify(metadata));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an API key exists for a type
|
||||
*
|
||||
* @param type - The API key type
|
||||
* @returns True if key exists
|
||||
*/
|
||||
export async function hasApiKey(type: ApiKeyType): Promise<boolean> {
|
||||
const key = await getApiKey(type);
|
||||
return key !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a stored API key
|
||||
*
|
||||
* @param type - The API key type
|
||||
* @returns Validation result
|
||||
*/
|
||||
export async function validateStoredApiKey(type: ApiKeyType): Promise<{
|
||||
valid: boolean;
|
||||
error?: string;
|
||||
}> {
|
||||
const key = await getApiKey(type);
|
||||
if (!key) {
|
||||
return { valid: false, error: 'API key not found' };
|
||||
}
|
||||
|
||||
return validateApiKeyFormat(type, key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotate an API key
|
||||
*
|
||||
* @param type - The API key type
|
||||
* @param newKey - The new API key value
|
||||
*/
|
||||
export async function rotateApiKey(type: ApiKeyType, newKey: string): Promise<ApiKeyMetadata> {
|
||||
// Delete old key first
|
||||
await deleteApiKey(type);
|
||||
|
||||
// Store new key
|
||||
return storeApiKey(type, newKey, {
|
||||
name: `${type}_api_key_rotated`,
|
||||
description: `Rotated at ${new Date().toISOString()}`,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Export API key configuration (without actual keys)
|
||||
* Useful for backup or migration
|
||||
*/
|
||||
export function exportApiKeyConfig(): Array<Omit<ApiKeyMetadata, 'keyHash'>> {
|
||||
return listApiKeyMetadata().map(({ keyHash: _, ...meta }) => meta);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if using OS keychain for storage
|
||||
*/
|
||||
export async function isUsingKeychain(): Promise<boolean> {
|
||||
return isSecureStorageAvailable();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Security Audit Logging
|
||||
// ============================================================================
|
||||
|
||||
interface SecurityEvent {
|
||||
type: string;
|
||||
timestamp: number;
|
||||
details: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const SECURITY_LOG_KEY = 'zclaw_security_events';
|
||||
const MAX_LOG_ENTRIES = 1000;
|
||||
|
||||
/**
|
||||
* Log a security event
|
||||
*/
|
||||
function logSecurityEvent(
|
||||
type: string,
|
||||
details: Record<string, unknown>
|
||||
): void {
|
||||
try {
|
||||
const events: SecurityEvent[] = JSON.parse(
|
||||
localStorage.getItem(SECURITY_LOG_KEY) || '[]'
|
||||
);
|
||||
|
||||
events.push({
|
||||
type,
|
||||
timestamp: Date.now(),
|
||||
details,
|
||||
});
|
||||
|
||||
// Trim old entries
|
||||
if (events.length > MAX_LOG_ENTRIES) {
|
||||
events.splice(0, events.length - MAX_LOG_ENTRIES);
|
||||
}
|
||||
|
||||
localStorage.setItem(SECURITY_LOG_KEY, JSON.stringify(events));
|
||||
} catch {
|
||||
// Ignore logging failures
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get security event log
|
||||
*/
|
||||
export function getSecurityLog(): SecurityEvent[] {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(SECURITY_LOG_KEY) || '[]');
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear security event log
|
||||
*/
|
||||
export function clearSecurityLog(): void {
|
||||
localStorage.removeItem(SECURITY_LOG_KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a random API key for testing
|
||||
* WARNING: Only use for testing purposes
|
||||
*/
|
||||
export function generateTestApiKey(type: ApiKeyType): string {
|
||||
const rules = KEY_VALIDATION_RULES[type];
|
||||
const length = rules.minLength + 10;
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||
|
||||
let key = '';
|
||||
if (rules.prefix && rules.prefix.length > 0) {
|
||||
key = rules.prefix[0];
|
||||
}
|
||||
|
||||
for (let i = key.length; i < length; i++) {
|
||||
key += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
|
||||
return key;
|
||||
}
|
||||
@@ -1,10 +1,18 @@
|
||||
/**
|
||||
* Cryptographic utilities for secure storage
|
||||
* Uses Web Crypto API for AES-GCM encryption
|
||||
*
|
||||
* Security features:
|
||||
* - AES-256-GCM for authenticated encryption
|
||||
* - PBKDF2 with 100,000 iterations for key derivation
|
||||
* - Random IV for each encryption operation
|
||||
* - Constant-time comparison for integrity verification
|
||||
* - Secure key caching with automatic expiration
|
||||
*/
|
||||
|
||||
const SALT = new TextEncoder().encode('zclaw-secure-storage-salt');
|
||||
const ITERATIONS = 100000;
|
||||
const KEY_EXPIRY_MS = 30 * 60 * 1000; // 30 minutes
|
||||
|
||||
/**
|
||||
* Convert Uint8Array to base64 string
|
||||
@@ -33,13 +41,64 @@ export function base64ToArray(base64: string): Uint8Array {
|
||||
return array;
|
||||
}
|
||||
|
||||
/**
|
||||
* Key cache entry with expiration
|
||||
*/
|
||||
interface CachedKey {
|
||||
key: CryptoKey;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache for derived keys with automatic expiration
|
||||
*/
|
||||
const keyCache = new Map<string, CachedKey>();
|
||||
|
||||
/**
|
||||
* Clean up expired keys from cache
|
||||
*/
|
||||
function cleanupExpiredKeys(): void {
|
||||
const now = Date.now();
|
||||
for (const [cacheKey, entry] of keyCache.entries()) {
|
||||
if (now - entry.createdAt > KEY_EXPIRY_MS) {
|
||||
keyCache.delete(cacheKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a cache key from master key and salt
|
||||
*/
|
||||
function getCacheKey(masterKey: string, salt: Uint8Array): string {
|
||||
const encoder = new TextEncoder();
|
||||
const combined = new Uint8Array(encoder.encode(masterKey).length + salt.length);
|
||||
combined.set(encoder.encode(masterKey), 0);
|
||||
combined.set(salt, encoder.encode(masterKey).length);
|
||||
return arrayToBase64(combined.slice(0, 32)); // Use first 32 bytes as cache key
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive an encryption key from a master key
|
||||
* Uses PBKDF2 with SHA-256 for key derivation
|
||||
*
|
||||
* @param masterKey - The master key string
|
||||
* @param salt - Optional salt (uses default if not provided)
|
||||
* @returns Promise<CryptoKey> - The derived encryption key
|
||||
*/
|
||||
export async function deriveKey(
|
||||
masterKey: string,
|
||||
salt: Uint8Array = SALT
|
||||
): Promise<CryptoKey> {
|
||||
// Clean up expired keys periodically
|
||||
cleanupExpiredKeys();
|
||||
|
||||
// Check cache first
|
||||
const cacheKey = getCacheKey(masterKey, salt);
|
||||
const cached = keyCache.get(cacheKey);
|
||||
if (cached && Date.now() - cached.createdAt < KEY_EXPIRY_MS) {
|
||||
return cached.key;
|
||||
}
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const keyMaterial = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
@@ -49,7 +108,7 @@ export async function deriveKey(
|
||||
['deriveBits', 'deriveKey']
|
||||
);
|
||||
|
||||
return crypto.subtle.deriveKey(
|
||||
const derivedKey = await crypto.subtle.deriveKey(
|
||||
{
|
||||
name: 'PBKDF2',
|
||||
salt,
|
||||
@@ -61,15 +120,39 @@ export async function deriveKey(
|
||||
false,
|
||||
['encrypt', 'decrypt']
|
||||
);
|
||||
|
||||
// Cache the derived key
|
||||
keyCache.set(cacheKey, { key: derivedKey, createdAt: Date.now() });
|
||||
|
||||
return derivedKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypted data structure
|
||||
*/
|
||||
export interface EncryptedData {
|
||||
iv: string;
|
||||
data: string;
|
||||
authTag?: string; // For future use with separate auth tag
|
||||
version?: number; // Schema version for future migrations
|
||||
}
|
||||
|
||||
/**
|
||||
* Current encryption schema version
|
||||
*/
|
||||
const ENCRYPTION_VERSION = 1;
|
||||
|
||||
/**
|
||||
* Encrypt data using AES-GCM
|
||||
*
|
||||
* @param plaintext - The plaintext string to encrypt
|
||||
* @param key - The encryption key
|
||||
* @returns Promise<EncryptedData> - The encrypted data with IV
|
||||
*/
|
||||
export async function encrypt(
|
||||
plaintext: string,
|
||||
key: CryptoKey
|
||||
): Promise<{ iv: string; data: string }> {
|
||||
): Promise<EncryptedData> {
|
||||
const encoder = new TextEncoder();
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
|
||||
@@ -82,14 +165,19 @@ export async function encrypt(
|
||||
return {
|
||||
iv: arrayToBase64(iv),
|
||||
data: arrayToBase64(new Uint8Array(encrypted)),
|
||||
version: ENCRYPTION_VERSION,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt data using AES-GCM
|
||||
*
|
||||
* @param encrypted - The encrypted data object
|
||||
* @param key - The decryption key
|
||||
* @returns Promise<string> - The decrypted plaintext
|
||||
*/
|
||||
export async function decrypt(
|
||||
encrypted: { iv: string; data: string },
|
||||
encrypted: EncryptedData,
|
||||
key: CryptoKey
|
||||
): Promise<string> {
|
||||
const decoder = new TextDecoder();
|
||||
@@ -104,8 +192,169 @@ export async function decrypt(
|
||||
|
||||
/**
|
||||
* Generate a random master key for encryption
|
||||
* Uses cryptographically secure random number generator
|
||||
*
|
||||
* @returns string - Base64-encoded 256-bit random key
|
||||
*/
|
||||
export function generateMasterKey(): string {
|
||||
const array = crypto.getRandomValues(new Uint8Array(32));
|
||||
return arrayToBase64(array);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a random salt
|
||||
*
|
||||
* @param length - Salt length in bytes (default: 16)
|
||||
* @returns Uint8Array - Random salt
|
||||
*/
|
||||
export function generateSalt(length: number = 16): Uint8Array {
|
||||
return crypto.getRandomValues(new Uint8Array(length));
|
||||
}
|
||||
|
||||
/**
|
||||
* Constant-time comparison to prevent timing attacks
|
||||
*
|
||||
* @param a - First byte array
|
||||
* @param b - Second byte array
|
||||
* @returns boolean - True if arrays are equal
|
||||
*/
|
||||
export function constantTimeEqual(a: Uint8Array, b: Uint8Array): boolean {
|
||||
if (a.length !== b.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let result = 0;
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
result |= a[i] ^ b[i];
|
||||
}
|
||||
|
||||
return result === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash a string using SHA-256
|
||||
*
|
||||
* @param input - The input string to hash
|
||||
* @returns Promise<string> - Hex-encoded hash
|
||||
*/
|
||||
export async function hashSha256(input: string): Promise<string> {
|
||||
const encoder = new TextEncoder();
|
||||
const data = encoder.encode(input);
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
|
||||
const hashArray = new Uint8Array(hashBuffer);
|
||||
|
||||
// Convert to hex string
|
||||
return Array.from(hashArray)
|
||||
.map(b => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash a string using SHA-512 (for sensitive data)
|
||||
*
|
||||
* @param input - The input string to hash
|
||||
* @returns Promise<string> - Hex-encoded hash
|
||||
*/
|
||||
export async function hashSha512(input: string): Promise<string> {
|
||||
const encoder = new TextEncoder();
|
||||
const data = encoder.encode(input);
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-512', data);
|
||||
const hashArray = new Uint8Array(hashBuffer);
|
||||
|
||||
return Array.from(hashArray)
|
||||
.map(b => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a cryptographically secure random string
|
||||
*
|
||||
* @param length - Length of the string (default: 32)
|
||||
* @returns string - Random alphanumeric string
|
||||
*/
|
||||
export function generateRandomString(length: number = 32): string {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||
const array = crypto.getRandomValues(new Uint8Array(length));
|
||||
let result = '';
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += chars[array[i] % chars.length];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the key cache (for logout or security events)
|
||||
*/
|
||||
export function clearKeyCache(): void {
|
||||
keyCache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt a JSON object
|
||||
*
|
||||
* @param obj - The object to encrypt
|
||||
* @param key - The encryption key
|
||||
* @returns Promise<EncryptedData> - The encrypted data
|
||||
*/
|
||||
export async function encryptObject<T>(
|
||||
obj: T,
|
||||
key: CryptoKey
|
||||
): Promise<EncryptedData> {
|
||||
const plaintext = JSON.stringify(obj);
|
||||
return encrypt(plaintext, key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt a JSON object
|
||||
*
|
||||
* @param encrypted - The encrypted data
|
||||
* @param key - The decryption key
|
||||
* @returns Promise<T> - The decrypted object
|
||||
*/
|
||||
export async function decryptObject<T>(
|
||||
encrypted: EncryptedData,
|
||||
key: CryptoKey
|
||||
): Promise<T> {
|
||||
const plaintext = await decrypt(encrypted, key);
|
||||
return JSON.parse(plaintext) as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Securely wipe a string from memory (best effort)
|
||||
* Note: JavaScript strings are immutable, so this only works for
|
||||
* data that was explicitly copied to a Uint8Array
|
||||
*
|
||||
* @param array - The byte array to wipe
|
||||
*/
|
||||
export function secureWipe(array: Uint8Array): void {
|
||||
crypto.getRandomValues(array);
|
||||
array.fill(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Web Crypto API is available
|
||||
*/
|
||||
export function isCryptoAvailable(): boolean {
|
||||
return (
|
||||
typeof crypto !== 'undefined' &&
|
||||
typeof crypto.subtle !== 'undefined' &&
|
||||
typeof crypto.getRandomValues === 'function'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate encrypted data structure
|
||||
*/
|
||||
export function isValidEncryptedData(data: unknown): data is EncryptedData {
|
||||
if (typeof data !== 'object' || data === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const obj = data as Record<string, unknown>;
|
||||
return (
|
||||
typeof obj.iv === 'string' &&
|
||||
typeof obj.data === 'string' &&
|
||||
obj.iv.length > 0 &&
|
||||
obj.data.length > 0
|
||||
);
|
||||
}
|
||||
|
||||
412
desktop/src/lib/encrypted-chat-storage.ts
Normal file
412
desktop/src/lib/encrypted-chat-storage.ts
Normal file
@@ -0,0 +1,412 @@
|
||||
/**
|
||||
* Encrypted Chat History Storage
|
||||
*
|
||||
* Provides encrypted persistence for chat messages and conversations.
|
||||
* Uses AES-256-GCM encryption with the secure storage infrastructure.
|
||||
*
|
||||
* Security features:
|
||||
* - All chat data encrypted at rest
|
||||
* - Master key stored in OS keychain when available
|
||||
* - Automatic key derivation with key rotation support
|
||||
* - Secure backup to encrypted localStorage
|
||||
*/
|
||||
|
||||
import {
|
||||
deriveKey,
|
||||
encryptObject,
|
||||
decryptObject,
|
||||
generateMasterKey,
|
||||
hashSha256,
|
||||
isValidEncryptedData,
|
||||
clearKeyCache,
|
||||
} from './crypto-utils';
|
||||
import { secureStorage, isSecureStorageAvailable } from './secure-storage';
|
||||
|
||||
// Storage keys
|
||||
const CHAT_DATA_KEY = 'zclaw_chat_data';
|
||||
const CHAT_KEY_IDENTIFIER = 'zclaw_chat_master_key';
|
||||
const CHAT_KEY_HASH_KEY = 'zclaw_chat_key_hash';
|
||||
const ENCRYPTED_PREFIX = 'enc_chat_';
|
||||
|
||||
// Encryption version for future migrations
|
||||
const STORAGE_VERSION = 1;
|
||||
|
||||
/**
|
||||
* Storage metadata for integrity verification
|
||||
*/
|
||||
interface StorageMetadata {
|
||||
version: number;
|
||||
keyHash: string;
|
||||
createdAt: number;
|
||||
lastAccessedAt: number;
|
||||
encryptedAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypted storage container
|
||||
*/
|
||||
interface EncryptedContainer {
|
||||
metadata: StorageMetadata;
|
||||
data: string; // Encrypted payload
|
||||
}
|
||||
|
||||
/**
|
||||
* Cached crypto key for chat encryption
|
||||
*/
|
||||
let cachedChatKey: CryptoKey | null = null;
|
||||
let keyHash: string | null = null;
|
||||
|
||||
/**
|
||||
* Get or initialize the master encryption key for chat storage
|
||||
* Uses OS keychain when available, falls back to encrypted localStorage
|
||||
*/
|
||||
async function getOrCreateMasterKey(): Promise<string> {
|
||||
// Try to get existing key from secure storage
|
||||
const existingKey = await secureStorage.get(CHAT_KEY_IDENTIFIER);
|
||||
if (existingKey) {
|
||||
return existingKey;
|
||||
}
|
||||
|
||||
// Generate new master key
|
||||
const newKey = generateMasterKey();
|
||||
|
||||
// Store in secure storage (keychain or encrypted localStorage)
|
||||
await secureStorage.set(CHAT_KEY_IDENTIFIER, newKey);
|
||||
|
||||
// Store hash for integrity verification
|
||||
const keyHashValue = await hashSha256(newKey);
|
||||
localStorage.setItem(CHAT_KEY_HASH_KEY, keyHashValue);
|
||||
|
||||
console.log('[EncryptedChatStorage] Generated new master key');
|
||||
return newKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the derived encryption key for chat data
|
||||
*/
|
||||
async function getChatEncryptionKey(): Promise<CryptoKey> {
|
||||
if (cachedChatKey && keyHash) {
|
||||
// Verify key hash matches
|
||||
const storedHash = localStorage.getItem(CHAT_KEY_HASH_KEY);
|
||||
if (storedHash === keyHash) {
|
||||
return cachedChatKey;
|
||||
}
|
||||
// Hash mismatch - clear cache and re-derive
|
||||
console.warn('[EncryptedChatStorage] Key hash mismatch, re-deriving key');
|
||||
cachedChatKey = null;
|
||||
keyHash = null;
|
||||
}
|
||||
|
||||
const masterKey = await getOrCreateMasterKey();
|
||||
cachedChatKey = await deriveKey(masterKey);
|
||||
keyHash = await hashSha256(masterKey);
|
||||
|
||||
return cachedChatKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize encrypted chat storage
|
||||
* Called during app startup
|
||||
*/
|
||||
export async function initializeEncryptedChatStorage(): Promise<void> {
|
||||
try {
|
||||
// Pre-load the encryption key
|
||||
await getChatEncryptionKey();
|
||||
|
||||
// Check if we have existing encrypted data to migrate
|
||||
const legacyData = localStorage.getItem('zclaw-chat-storage');
|
||||
if (legacyData && !localStorage.getItem(ENCRYPTED_PREFIX + 'migrated')) {
|
||||
await migrateFromLegacyStorage(legacyData);
|
||||
localStorage.setItem(ENCRYPTED_PREFIX + 'migrated', 'true');
|
||||
console.log('[EncryptedChatStorage] Migrated legacy data');
|
||||
}
|
||||
|
||||
console.log('[EncryptedChatStorage] Initialized successfully');
|
||||
} catch (error) {
|
||||
console.error('[EncryptedChatStorage] Initialization failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate data from legacy unencrypted storage
|
||||
*/
|
||||
async function migrateFromLegacyStorage(legacyData: string): Promise<void> {
|
||||
try {
|
||||
const parsed = JSON.parse(legacyData);
|
||||
if (parsed?.state?.conversations) {
|
||||
await saveConversations(parsed.state.conversations);
|
||||
console.log(`[EncryptedChatStorage] Migrated ${parsed.state.conversations.length} conversations`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[EncryptedChatStorage] Migration failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save conversations to encrypted storage
|
||||
*
|
||||
* @param conversations - Array of conversation objects
|
||||
*/
|
||||
export async function saveConversations(conversations: unknown[]): Promise<void> {
|
||||
if (!conversations || conversations.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const key = await getChatEncryptionKey();
|
||||
const now = Date.now();
|
||||
|
||||
// Create container with metadata
|
||||
const container: EncryptedContainer = {
|
||||
metadata: {
|
||||
version: STORAGE_VERSION,
|
||||
keyHash: keyHash || '',
|
||||
createdAt: now,
|
||||
lastAccessedAt: now,
|
||||
encryptedAt: now,
|
||||
},
|
||||
data: '', // Will be set after encryption
|
||||
};
|
||||
|
||||
// Encrypt the conversations array
|
||||
const encrypted = await encryptObject(conversations, key);
|
||||
container.data = JSON.stringify(encrypted);
|
||||
|
||||
// Store the encrypted container
|
||||
localStorage.setItem(CHAT_DATA_KEY, JSON.stringify(container));
|
||||
|
||||
console.log(`[EncryptedChatStorage] Saved ${conversations.length} conversations`);
|
||||
} catch (error) {
|
||||
console.error('[EncryptedChatStorage] Failed to save conversations:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load conversations from encrypted storage
|
||||
*
|
||||
* @returns Array of conversation objects or empty array if none exist
|
||||
*/
|
||||
export async function loadConversations<T = unknown>(): Promise<T[]> {
|
||||
try {
|
||||
const stored = localStorage.getItem(CHAT_DATA_KEY);
|
||||
if (!stored) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const container: EncryptedContainer = JSON.parse(stored);
|
||||
|
||||
// Validate container structure
|
||||
if (!container.data || !container.metadata) {
|
||||
console.warn('[EncryptedChatStorage] Invalid container structure');
|
||||
return [];
|
||||
}
|
||||
|
||||
// Check version compatibility
|
||||
if (container.metadata.version > STORAGE_VERSION) {
|
||||
console.error('[EncryptedChatStorage] Incompatible storage version');
|
||||
return [];
|
||||
}
|
||||
|
||||
// Parse and decrypt the data
|
||||
const encryptedData = JSON.parse(container.data);
|
||||
if (!isValidEncryptedData(encryptedData)) {
|
||||
console.error('[EncryptedChatStorage] Invalid encrypted data');
|
||||
return [];
|
||||
}
|
||||
|
||||
const key = await getChatEncryptionKey();
|
||||
const conversations = await decryptObject<T[]>(encryptedData, key);
|
||||
|
||||
// Update last accessed time
|
||||
container.metadata.lastAccessedAt = Date.now();
|
||||
localStorage.setItem(CHAT_DATA_KEY, JSON.stringify(container));
|
||||
|
||||
console.log(`[EncryptedChatStorage] Loaded ${conversations.length} conversations`);
|
||||
return conversations;
|
||||
} catch (error) {
|
||||
console.error('[EncryptedChatStorage] Failed to load conversations:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all chat data from storage
|
||||
*/
|
||||
export async function clearAllChatData(): Promise<void> {
|
||||
try {
|
||||
// Clear encrypted data
|
||||
localStorage.removeItem(CHAT_DATA_KEY);
|
||||
localStorage.removeItem(ENCRYPTED_PREFIX + 'migrated');
|
||||
|
||||
// Clear the master key from secure storage
|
||||
await secureStorage.delete(CHAT_KEY_IDENTIFIER);
|
||||
localStorage.removeItem(CHAT_KEY_HASH_KEY);
|
||||
|
||||
// Clear cached key
|
||||
cachedChatKey = null;
|
||||
keyHash = null;
|
||||
clearKeyCache();
|
||||
|
||||
console.log('[EncryptedChatStorage] Cleared all chat data');
|
||||
} catch (error) {
|
||||
console.error('[EncryptedChatStorage] Failed to clear chat data:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Export encrypted chat data for backup
|
||||
* Returns encrypted blob that can be imported later
|
||||
*
|
||||
* @returns Base64-encoded encrypted backup
|
||||
*/
|
||||
export async function exportEncryptedBackup(): Promise<string> {
|
||||
try {
|
||||
const stored = localStorage.getItem(CHAT_DATA_KEY);
|
||||
if (!stored) {
|
||||
throw new Error('No chat data to export');
|
||||
}
|
||||
|
||||
// The data is already encrypted, just return it
|
||||
const container: EncryptedContainer = JSON.parse(stored);
|
||||
const exportData = {
|
||||
type: 'zclaw_chat_backup',
|
||||
version: STORAGE_VERSION,
|
||||
exportedAt: Date.now(),
|
||||
container,
|
||||
};
|
||||
|
||||
return btoa(JSON.stringify(exportData));
|
||||
} catch (error) {
|
||||
console.error('[EncryptedChatStorage] Export failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Import encrypted chat data from backup
|
||||
*
|
||||
* @param backupData - Base64-encoded encrypted backup
|
||||
* @param merge - Whether to merge with existing data (default: false, replaces)
|
||||
*/
|
||||
export async function importEncryptedBackup(
|
||||
backupData: string,
|
||||
merge: boolean = false
|
||||
): Promise<void> {
|
||||
try {
|
||||
const decoded = JSON.parse(atob(backupData));
|
||||
|
||||
// Validate backup format
|
||||
if (decoded.type !== 'zclaw_chat_backup') {
|
||||
throw new Error('Invalid backup format');
|
||||
}
|
||||
|
||||
if (decoded.version > STORAGE_VERSION) {
|
||||
throw new Error('Incompatible backup version');
|
||||
}
|
||||
|
||||
if (merge) {
|
||||
// Load existing conversations and merge
|
||||
const existing = await loadConversations();
|
||||
const imported = await decryptObject<unknown[]>(
|
||||
JSON.parse(decoded.container.data),
|
||||
await getChatEncryptionKey()
|
||||
);
|
||||
const merged = [...existing, ...imported];
|
||||
await saveConversations(merged);
|
||||
} else {
|
||||
// Replace existing data
|
||||
localStorage.setItem(CHAT_DATA_KEY, JSON.stringify(decoded.container));
|
||||
}
|
||||
|
||||
console.log('[EncryptedChatStorage] Import completed successfully');
|
||||
} catch (error) {
|
||||
console.error('[EncryptedChatStorage] Import failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if encrypted storage is being used
|
||||
*/
|
||||
export async function isEncryptedStorageActive(): Promise<boolean> {
|
||||
const stored = localStorage.getItem(CHAT_DATA_KEY);
|
||||
if (!stored) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const container: EncryptedContainer = JSON.parse(stored);
|
||||
return container.metadata?.version === STORAGE_VERSION;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get storage statistics
|
||||
*/
|
||||
export async function getStorageStats(): Promise<{
|
||||
encrypted: boolean;
|
||||
usingKeychain: boolean;
|
||||
conversationCount: number;
|
||||
storageSize: number;
|
||||
}> {
|
||||
const stored = localStorage.getItem(CHAT_DATA_KEY);
|
||||
let conversationCount = 0;
|
||||
let encrypted = false;
|
||||
|
||||
if (stored) {
|
||||
try {
|
||||
const container: EncryptedContainer = JSON.parse(stored);
|
||||
encrypted = container.metadata?.version === STORAGE_VERSION;
|
||||
|
||||
// Count conversations without full decryption
|
||||
const conversations = await loadConversations();
|
||||
conversationCount = conversations.length;
|
||||
} catch {
|
||||
// Ignore parsing errors
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
encrypted,
|
||||
usingKeychain: await isSecureStorageAvailable(),
|
||||
conversationCount,
|
||||
storageSize: stored ? new Blob([stored]).size : 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotate encryption key
|
||||
* Re-encrypts all data with a new key
|
||||
*/
|
||||
export async function rotateEncryptionKey(): Promise<void> {
|
||||
try {
|
||||
// Load existing data
|
||||
const conversations = await loadConversations();
|
||||
|
||||
// Clear old key
|
||||
await secureStorage.delete(CHAT_KEY_IDENTIFIER);
|
||||
localStorage.removeItem(CHAT_KEY_HASH_KEY);
|
||||
cachedChatKey = null;
|
||||
keyHash = null;
|
||||
clearKeyCache();
|
||||
|
||||
// Generate new key (will be created on next getChatEncryptionKey call)
|
||||
const newKey = generateMasterKey();
|
||||
await secureStorage.set(CHAT_KEY_IDENTIFIER, newKey);
|
||||
const newKeyHash = await hashSha256(newKey);
|
||||
localStorage.setItem(CHAT_KEY_HASH_KEY, newKeyHash);
|
||||
|
||||
// Re-save all data with new key
|
||||
await saveConversations(conversations);
|
||||
|
||||
console.log('[EncryptedChatStorage] Encryption key rotated successfully');
|
||||
} catch (error) {
|
||||
console.error('[EncryptedChatStorage] Key rotation failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -87,6 +87,47 @@ export class SecurityError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Connection error for WebSocket/HTTP connection failures.
|
||||
*/
|
||||
export class ConnectionError extends Error {
|
||||
public readonly code?: string;
|
||||
public readonly recoverable: boolean;
|
||||
|
||||
constructor(message: string, code?: string, recoverable: boolean = true) {
|
||||
super(message);
|
||||
this.name = 'ConnectionError';
|
||||
this.code = code;
|
||||
this.recoverable = recoverable;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Timeout error for request/response timeouts.
|
||||
*/
|
||||
export class TimeoutError extends Error {
|
||||
public readonly timeout: number;
|
||||
|
||||
constructor(message: string, timeout: number) {
|
||||
super(message);
|
||||
this.name = 'TimeoutError';
|
||||
this.timeout = timeout;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Authentication error for handshake/token failures.
|
||||
*/
|
||||
export class AuthenticationError extends Error {
|
||||
public readonly code?: string;
|
||||
|
||||
constructor(message: string, code?: string) {
|
||||
super(message);
|
||||
this.name = 'AuthenticationError';
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate WebSocket URL security.
|
||||
* Ensures non-localhost connections use WSS protocol.
|
||||
|
||||
@@ -3,9 +3,14 @@
|
||||
*
|
||||
* Extracted from gateway-client.ts for modularity.
|
||||
* Manages WSS configuration, URL normalization, and
|
||||
* localStorage persistence for gateway URL and token.
|
||||
* secure storage persistence for gateway URL and token.
|
||||
*
|
||||
* Security: Token is now stored using secure storage (keychain or encrypted localStorage)
|
||||
*/
|
||||
|
||||
import { secureStorage } from './secure-storage';
|
||||
import { logKeyEvent, logSecurityEvent } from './security-audit';
|
||||
|
||||
// === WSS Configuration ===
|
||||
|
||||
/**
|
||||
@@ -95,18 +100,104 @@ export function setStoredGatewayUrl(url: string): string {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function getStoredGatewayToken(): string {
|
||||
/**
|
||||
* Get the stored gateway token from secure storage
|
||||
* Uses OS keychain when available, falls back to encrypted localStorage
|
||||
*
|
||||
* @returns The stored token or empty string if not found
|
||||
*/
|
||||
export async function getStoredGatewayTokenAsync(): Promise<string> {
|
||||
try {
|
||||
return localStorage.getItem(GATEWAY_TOKEN_STORAGE_KEY) || '';
|
||||
const token = await secureStorage.get(GATEWAY_TOKEN_STORAGE_KEY);
|
||||
if (token) {
|
||||
logKeyEvent('key_accessed', 'Retrieved gateway token', { source: 'secure_storage' });
|
||||
}
|
||||
return token || '';
|
||||
} catch (error) {
|
||||
console.error('[GatewayStorage] Failed to get gateway token:', error);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronous version for backward compatibility
|
||||
* @deprecated Use getStoredGatewayTokenAsync() instead
|
||||
*/
|
||||
export function getStoredGatewayToken(): string {
|
||||
// This returns empty string and logs a warning in dev mode
|
||||
// Real code should use the async version
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.warn('[GatewayStorage] Using synchronous token access - consider using async version');
|
||||
}
|
||||
|
||||
// Try to get from localStorage as fallback (may be encrypted)
|
||||
try {
|
||||
const stored = localStorage.getItem(GATEWAY_TOKEN_STORAGE_KEY);
|
||||
if (stored) {
|
||||
// Check if it's encrypted (has iv and data fields)
|
||||
try {
|
||||
const parsed = JSON.parse(stored);
|
||||
if (parsed && typeof parsed.iv === 'string' && typeof parsed.data === 'string') {
|
||||
// Data is encrypted - cannot decrypt synchronously
|
||||
console.warn('[GatewayStorage] Token is encrypted - use async version');
|
||||
return '';
|
||||
}
|
||||
} catch {
|
||||
// Not JSON, so it's plaintext (legacy format)
|
||||
return stored;
|
||||
}
|
||||
}
|
||||
return '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export function setStoredGatewayToken(token: string): string {
|
||||
/**
|
||||
* Store the gateway token securely
|
||||
* Uses OS keychain when available, falls back to encrypted localStorage
|
||||
*
|
||||
* @param token - The token to store
|
||||
* @returns The normalized token
|
||||
*/
|
||||
export async function setStoredGatewayTokenAsync(token: string): Promise<string> {
|
||||
const normalized = token.trim();
|
||||
|
||||
try {
|
||||
if (normalized) {
|
||||
await secureStorage.set(GATEWAY_TOKEN_STORAGE_KEY, normalized);
|
||||
logKeyEvent('key_stored', 'Stored gateway token', { source: 'secure_storage' });
|
||||
} else {
|
||||
await secureStorage.delete(GATEWAY_TOKEN_STORAGE_KEY);
|
||||
logKeyEvent('key_deleted', 'Deleted gateway token', { source: 'secure_storage' });
|
||||
}
|
||||
|
||||
// Clear legacy localStorage token if it exists
|
||||
localStorage.removeItem(GATEWAY_TOKEN_STORAGE_KEY);
|
||||
} catch (error) {
|
||||
console.error('[GatewayStorage] Failed to store gateway token:', error);
|
||||
logSecurityEvent('security_violation', 'Failed to store gateway token securely', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronous version for backward compatibility
|
||||
* @deprecated Use setStoredGatewayTokenAsync() instead
|
||||
*/
|
||||
export function setStoredGatewayToken(token: string): string {
|
||||
const normalized = token.trim();
|
||||
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.warn('[GatewayStorage] Using synchronous token storage - consider using async version');
|
||||
}
|
||||
|
||||
try {
|
||||
if (normalized) {
|
||||
// Store in localStorage as fallback (not secure, but better than nothing)
|
||||
localStorage.setItem(GATEWAY_TOKEN_STORAGE_KEY, normalized);
|
||||
} else {
|
||||
localStorage.removeItem(GATEWAY_TOKEN_STORAGE_KEY);
|
||||
@@ -114,5 +205,6 @@ export function setStoredGatewayToken(token: string): string {
|
||||
} catch {
|
||||
/* ignore localStorage failures */
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
564
desktop/src/lib/security-audit.ts
Normal file
564
desktop/src/lib/security-audit.ts
Normal file
@@ -0,0 +1,564 @@
|
||||
/**
|
||||
* Security Audit Logging Module
|
||||
*
|
||||
* Provides comprehensive security event logging for ZCLAW application.
|
||||
* All security-relevant events are logged with timestamps and details.
|
||||
*
|
||||
* Security events logged:
|
||||
* - Authentication events (login, logout, failed attempts)
|
||||
* - API key operations (access, rotation, deletion)
|
||||
* - Data access events (encrypted data read/write)
|
||||
* - Security violations (failed decryption, tampering attempts)
|
||||
* - Configuration changes
|
||||
*/
|
||||
|
||||
import { hashSha256 } from './crypto-utils';
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
export type SecurityEventType =
|
||||
| 'auth_login'
|
||||
| 'auth_logout'
|
||||
| 'auth_failed'
|
||||
| 'auth_token_refresh'
|
||||
| 'key_accessed'
|
||||
| 'key_stored'
|
||||
| 'key_deleted'
|
||||
| 'key_rotated'
|
||||
| 'data_encrypted'
|
||||
| 'data_decrypted'
|
||||
| 'data_access'
|
||||
| 'data_export'
|
||||
| 'data_import'
|
||||
| 'security_violation'
|
||||
| 'decryption_failed'
|
||||
| 'integrity_check_failed'
|
||||
| 'config_changed'
|
||||
| 'permission_granted'
|
||||
| 'permission_denied'
|
||||
| 'session_started'
|
||||
| 'session_ended'
|
||||
| 'rate_limit_exceeded'
|
||||
| 'suspicious_activity';
|
||||
|
||||
export type SecurityEventSeverity = 'info' | 'warning' | 'error' | 'critical';
|
||||
|
||||
export interface SecurityEvent {
|
||||
id: string;
|
||||
type: SecurityEventType;
|
||||
severity: SecurityEventSeverity;
|
||||
timestamp: string;
|
||||
message: string;
|
||||
details: Record<string, unknown>;
|
||||
userAgent?: string;
|
||||
ip?: string;
|
||||
sessionId?: string;
|
||||
agentId?: string;
|
||||
}
|
||||
|
||||
export interface SecurityAuditReport {
|
||||
generatedAt: string;
|
||||
totalEvents: number;
|
||||
eventsByType: Record<SecurityEventType, number>;
|
||||
eventsBySeverity: Record<SecurityEventSeverity, number>;
|
||||
recentCriticalEvents: SecurityEvent[];
|
||||
recommendations: string[];
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Constants
|
||||
// ============================================================================
|
||||
|
||||
const SECURITY_LOG_KEY = 'zclaw_security_audit_log';
|
||||
const MAX_LOG_ENTRIES = 2000;
|
||||
const AUDIT_VERSION = 1;
|
||||
|
||||
// ============================================================================
|
||||
// Internal State
|
||||
// ============================================================================
|
||||
|
||||
let isAuditEnabled: boolean = true;
|
||||
let currentSessionId: string | null = null;
|
||||
|
||||
// ============================================================================
|
||||
// Core Functions
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Generate a unique event ID
|
||||
*/
|
||||
function generateEventId(): string {
|
||||
return `evt_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current session ID
|
||||
*/
|
||||
export function getCurrentSessionId(): string | null {
|
||||
return currentSessionId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the current session ID
|
||||
*/
|
||||
export function setCurrentSessionId(sessionId: string | null): void {
|
||||
currentSessionId = sessionId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable or disable audit logging
|
||||
*/
|
||||
export function setAuditEnabled(enabled: boolean): void {
|
||||
isAuditEnabled = enabled;
|
||||
logSecurityEventInternal('config_changed', 'info', `Audit logging ${enabled ? 'enabled' : 'disabled'}`, {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if audit logging is enabled
|
||||
*/
|
||||
export function isAuditEnabledState(): boolean {
|
||||
return isAuditEnabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal function to persist security events
|
||||
*/
|
||||
function persistEvent(event: SecurityEvent): void {
|
||||
try {
|
||||
const events = getStoredEvents();
|
||||
events.push(event);
|
||||
|
||||
// Trim old entries if needed
|
||||
if (events.length > MAX_LOG_ENTRIES) {
|
||||
events.splice(0, events.length - MAX_LOG_ENTRIES);
|
||||
}
|
||||
|
||||
localStorage.setItem(SECURITY_LOG_KEY, JSON.stringify(events));
|
||||
} catch {
|
||||
// Ignore persistence failures to prevent application disruption
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get stored security events
|
||||
*/
|
||||
function getStoredEvents(): SecurityEvent[] {
|
||||
try {
|
||||
const stored = localStorage.getItem(SECURITY_LOG_KEY);
|
||||
if (!stored) return [];
|
||||
return JSON.parse(stored) as SecurityEvent[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine severity based on event type
|
||||
*/
|
||||
function getDefaultSeverity(type: SecurityEventType): SecurityEventSeverity {
|
||||
const severityMap: Record<SecurityEventType, SecurityEventSeverity> = {
|
||||
auth_login: 'info',
|
||||
auth_logout: 'info',
|
||||
auth_failed: 'warning',
|
||||
auth_token_refresh: 'info',
|
||||
key_accessed: 'info',
|
||||
key_stored: 'info',
|
||||
key_deleted: 'warning',
|
||||
key_rotated: 'info',
|
||||
data_encrypted: 'info',
|
||||
data_decrypted: 'info',
|
||||
data_access: 'info',
|
||||
data_export: 'warning',
|
||||
data_import: 'warning',
|
||||
security_violation: 'critical',
|
||||
decryption_failed: 'error',
|
||||
integrity_check_failed: 'critical',
|
||||
config_changed: 'warning',
|
||||
permission_granted: 'info',
|
||||
permission_denied: 'warning',
|
||||
session_started: 'info',
|
||||
session_ended: 'info',
|
||||
rate_limit_exceeded: 'warning',
|
||||
suspicious_activity: 'critical',
|
||||
};
|
||||
|
||||
return severityMap[type] || 'info';
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal function to log security events
|
||||
*/
|
||||
function logSecurityEventInternal(
|
||||
type: SecurityEventType,
|
||||
severity: SecurityEventSeverity,
|
||||
message: string,
|
||||
details: Record<string, unknown>
|
||||
): void {
|
||||
if (!isAuditEnabled && type !== 'config_changed') {
|
||||
return;
|
||||
}
|
||||
|
||||
const event: SecurityEvent = {
|
||||
id: generateEventId(),
|
||||
type,
|
||||
severity,
|
||||
timestamp: new Date().toISOString(),
|
||||
message,
|
||||
details,
|
||||
sessionId: currentSessionId || undefined,
|
||||
};
|
||||
|
||||
// Add user agent if in browser
|
||||
if (typeof navigator !== 'undefined') {
|
||||
event.userAgent = navigator.userAgent;
|
||||
}
|
||||
|
||||
persistEvent(event);
|
||||
|
||||
// Log to console for development
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
const logMethod = severity === 'critical' || severity === 'error' ? 'error' :
|
||||
severity === 'warning' ? 'warn' : 'log';
|
||||
console[logMethod](`[SecurityAudit] ${type}: ${message}`, details);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Public API
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Log a security event
|
||||
*/
|
||||
export function logSecurityEvent(
|
||||
type: SecurityEventType,
|
||||
message: string,
|
||||
details: Record<string, unknown> = {},
|
||||
severity?: SecurityEventSeverity
|
||||
): void {
|
||||
const eventSeverity = severity || getDefaultSeverity(type);
|
||||
logSecurityEventInternal(type, eventSeverity, message, details);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log authentication event
|
||||
*/
|
||||
export function logAuthEvent(
|
||||
type: 'auth_login' | 'auth_logout' | 'auth_failed' | 'auth_token_refresh',
|
||||
message: string,
|
||||
details: Record<string, unknown> = {}
|
||||
): void {
|
||||
logSecurityEvent(type, message, details);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log key management event
|
||||
*/
|
||||
export function logKeyEvent(
|
||||
type: 'key_accessed' | 'key_stored' | 'key_deleted' | 'key_rotated',
|
||||
message: string,
|
||||
details: Record<string, unknown> = {}
|
||||
): void {
|
||||
logSecurityEvent(type, message, details);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log data access event
|
||||
*/
|
||||
export function logDataEvent(
|
||||
type: 'data_encrypted' | 'data_decrypted' | 'data_access' | 'data_export' | 'data_import',
|
||||
message: string,
|
||||
details: Record<string, unknown> = {}
|
||||
): void {
|
||||
logSecurityEvent(type, message, details);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log security violation
|
||||
*/
|
||||
export function logSecurityViolation(
|
||||
message: string,
|
||||
details: Record<string, unknown> = {}
|
||||
): void {
|
||||
logSecurityEvent('security_violation', message, details, 'critical');
|
||||
}
|
||||
|
||||
/**
|
||||
* Log decryption failure
|
||||
*/
|
||||
export function logDecryptionFailure(
|
||||
message: string,
|
||||
details: Record<string, unknown> = {}
|
||||
): void {
|
||||
logSecurityEvent('decryption_failed', message, details, 'error');
|
||||
}
|
||||
|
||||
/**
|
||||
* Log integrity check failure
|
||||
*/
|
||||
export function logIntegrityFailure(
|
||||
message: string,
|
||||
details: Record<string, unknown> = {}
|
||||
): void {
|
||||
logSecurityEvent('integrity_check_failed', message, details, 'critical');
|
||||
}
|
||||
|
||||
/**
|
||||
* Log permission event
|
||||
*/
|
||||
export function logPermissionEvent(
|
||||
type: 'permission_granted' | 'permission_denied',
|
||||
message: string,
|
||||
details: Record<string, unknown> = {}
|
||||
): void {
|
||||
logSecurityEvent(type, message, details);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log session event
|
||||
*/
|
||||
export function logSessionEvent(
|
||||
type: 'session_started' | 'session_ended',
|
||||
message: string,
|
||||
details: Record<string, unknown> = {}
|
||||
): void {
|
||||
logSecurityEvent(type, message, details);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log suspicious activity
|
||||
*/
|
||||
export function logSuspiciousActivity(
|
||||
message: string,
|
||||
details: Record<string, unknown> = {}
|
||||
): void {
|
||||
logSecurityEvent('suspicious_activity', message, details, 'critical');
|
||||
}
|
||||
|
||||
/**
|
||||
* Log rate limit event
|
||||
*/
|
||||
export function logRateLimitEvent(
|
||||
message: string,
|
||||
details: Record<string, unknown> = {}
|
||||
): void {
|
||||
logSecurityEvent('rate_limit_exceeded', message, details, 'warning');
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Query Functions
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get all security events
|
||||
*/
|
||||
export function getSecurityEvents(): SecurityEvent[] {
|
||||
return getStoredEvents();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get security events by type
|
||||
*/
|
||||
export function getSecurityEventsByType(type: SecurityEventType): SecurityEvent[] {
|
||||
return getStoredEvents().filter(event => event.type === type);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get security events by severity
|
||||
*/
|
||||
export function getSecurityEventsBySeverity(severity: SecurityEventSeverity): SecurityEvent[] {
|
||||
return getStoredEvents().filter(event => event.severity === severity);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get security events within a time range
|
||||
*/
|
||||
export function getSecurityEventsByTimeRange(start: Date, end: Date): SecurityEvent[] {
|
||||
const startTime = start.getTime();
|
||||
const endTime = end.getTime();
|
||||
|
||||
return getStoredEvents().filter(event => {
|
||||
const eventTime = new Date(event.timestamp).getTime();
|
||||
return eventTime >= startTime && eventTime <= endTime;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recent critical events
|
||||
*/
|
||||
export function getRecentCriticalEvents(count: number = 10): SecurityEvent[] {
|
||||
return getStoredEvents()
|
||||
.filter(event => event.severity === 'critical' || event.severity === 'error')
|
||||
.slice(-count);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get events for a specific session
|
||||
*/
|
||||
export function getSecurityEventsBySession(sessionId: string): SecurityEvent[] {
|
||||
return getStoredEvents().filter(event => event.sessionId === sessionId);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Report Generation
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Generate a security audit report
|
||||
*/
|
||||
export function generateSecurityAuditReport(): SecurityAuditReport {
|
||||
const events = getStoredEvents();
|
||||
|
||||
const eventsByType = Object.create(null) as Record<SecurityEventType, number>;
|
||||
const eventsBySeverity: Record<SecurityEventSeverity, number> = {
|
||||
info: 0,
|
||||
warning: 0,
|
||||
error: 0,
|
||||
critical: 0,
|
||||
};
|
||||
|
||||
for (const event of events) {
|
||||
eventsByType[event.type] = (eventsByType[event.type] || 0) + 1;
|
||||
eventsBySeverity[event.severity]++;
|
||||
}
|
||||
|
||||
const recentCriticalEvents = getRecentCriticalEvents(10);
|
||||
|
||||
const recommendations: string[] = [];
|
||||
|
||||
// Generate recommendations based on findings
|
||||
if (eventsBySeverity.critical > 0) {
|
||||
recommendations.push('Investigate critical security events immediately');
|
||||
}
|
||||
|
||||
if ((eventsByType.auth_failed || 0) > 5) {
|
||||
recommendations.push('Multiple failed authentication attempts detected - consider rate limiting');
|
||||
}
|
||||
|
||||
if ((eventsByType.decryption_failed || 0) > 3) {
|
||||
recommendations.push('Multiple decryption failures - check key integrity');
|
||||
}
|
||||
|
||||
if ((eventsByType.suspicious_activity || 0) > 0) {
|
||||
recommendations.push('Suspicious activity detected - review access logs');
|
||||
}
|
||||
|
||||
if (events.length === 0) {
|
||||
recommendations.push('No security events recorded - ensure audit logging is enabled');
|
||||
}
|
||||
|
||||
return {
|
||||
generatedAt: new Date().toISOString(),
|
||||
totalEvents: events.length,
|
||||
eventsByType,
|
||||
eventsBySeverity,
|
||||
recentCriticalEvents,
|
||||
recommendations,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Maintenance Functions
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Clear all security events
|
||||
*/
|
||||
export function clearSecurityAuditLog(): void {
|
||||
localStorage.removeItem(SECURITY_LOG_KEY);
|
||||
logSecurityEventInternal('config_changed', 'warning', 'Security audit log cleared', {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Export security events for external analysis
|
||||
*/
|
||||
export function exportSecurityEvents(): string {
|
||||
const events = getStoredEvents();
|
||||
return JSON.stringify({
|
||||
version: AUDIT_VERSION,
|
||||
exportedAt: new Date().toISOString(),
|
||||
events,
|
||||
}, null, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Import security events from external source
|
||||
*/
|
||||
export function importSecurityEvents(jsonData: string, merge: boolean = false): void {
|
||||
try {
|
||||
const data = JSON.parse(jsonData);
|
||||
const importedEvents = data.events as SecurityEvent[];
|
||||
|
||||
if (!importedEvents || !Array.isArray(importedEvents)) {
|
||||
throw new Error('Invalid import data format');
|
||||
}
|
||||
|
||||
if (merge) {
|
||||
const existingEvents = getStoredEvents();
|
||||
const mergedEvents = [...existingEvents, ...importedEvents];
|
||||
localStorage.setItem(SECURITY_LOG_KEY, JSON.stringify(mergedEvents.slice(-MAX_LOG_ENTRIES)));
|
||||
} else {
|
||||
localStorage.setItem(SECURITY_LOG_KEY, JSON.stringify(importedEvents.slice(-MAX_LOG_ENTRIES)));
|
||||
}
|
||||
|
||||
logSecurityEventInternal('data_import', 'warning', `Imported ${importedEvents.length} security events`, {
|
||||
merge,
|
||||
sourceVersion: data.version,
|
||||
});
|
||||
} catch (error) {
|
||||
logSecurityEventInternal('security_violation', 'error', 'Failed to import security events', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify audit log integrity
|
||||
*/
|
||||
export async function verifyAuditLogIntegrity(): Promise<{
|
||||
valid: boolean;
|
||||
eventCount: number;
|
||||
hash: string;
|
||||
}> {
|
||||
const events = getStoredEvents();
|
||||
const data = JSON.stringify(events);
|
||||
const hash = await hashSha256(data);
|
||||
|
||||
return {
|
||||
valid: events.length > 0,
|
||||
eventCount: events.length,
|
||||
hash,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Initialization
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Initialize the security audit module
|
||||
*/
|
||||
export function initializeSecurityAudit(sessionId?: string): void {
|
||||
if (sessionId) {
|
||||
currentSessionId = sessionId;
|
||||
}
|
||||
|
||||
logSecurityEventInternal('session_started', 'info', 'Security audit session started', {
|
||||
sessionId: currentSessionId,
|
||||
auditEnabled: isAuditEnabled,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Shutdown the security audit module
|
||||
*/
|
||||
export function shutdownSecurityAudit(): void {
|
||||
logSecurityEventInternal('session_ended', 'info', 'Security audit session ended', {
|
||||
sessionId: currentSessionId,
|
||||
});
|
||||
|
||||
currentSessionId = null;
|
||||
}
|
||||
241
desktop/src/lib/security-index.ts
Normal file
241
desktop/src/lib/security-index.ts
Normal file
@@ -0,0 +1,241 @@
|
||||
/**
|
||||
* Security Module Index
|
||||
*
|
||||
* Central export point for all security-related functionality in ZCLAW.
|
||||
*
|
||||
* Modules:
|
||||
* - crypto-utils: AES-256-GCM encryption, key derivation, hashing
|
||||
* - secure-storage: OS keychain integration with encrypted localStorage fallback
|
||||
* - api-key-storage: Secure API key management
|
||||
* - encrypted-chat-storage: Encrypted chat history persistence
|
||||
* - security-audit: Security event logging and reporting
|
||||
* - security-utils: Input validation, XSS prevention, rate limiting
|
||||
*/
|
||||
|
||||
// Re-export crypto utilities
|
||||
export {
|
||||
// Core encryption
|
||||
encrypt,
|
||||
decrypt,
|
||||
encryptObject,
|
||||
decryptObject,
|
||||
deriveKey,
|
||||
generateMasterKey,
|
||||
generateSalt,
|
||||
|
||||
// Hashing
|
||||
hashSha256,
|
||||
hashSha512,
|
||||
|
||||
// Utilities
|
||||
arrayToBase64,
|
||||
base64ToArray,
|
||||
constantTimeEqual,
|
||||
generateRandomString,
|
||||
secureWipe,
|
||||
clearKeyCache,
|
||||
isCryptoAvailable,
|
||||
isValidEncryptedData,
|
||||
} from './crypto-utils';
|
||||
|
||||
export type { EncryptedData } from './crypto-utils';
|
||||
|
||||
// Re-export secure storage
|
||||
export {
|
||||
secureStorage,
|
||||
secureStorageSync,
|
||||
isSecureStorageAvailable,
|
||||
storeDeviceKeys,
|
||||
getDeviceKeys,
|
||||
deleteDeviceKeys,
|
||||
hasDeviceKeys,
|
||||
getDeviceKeysCreatedAt,
|
||||
} from './secure-storage';
|
||||
|
||||
export type { Ed25519KeyPair } from './secure-storage';
|
||||
|
||||
// Re-export API key storage
|
||||
export {
|
||||
// Types
|
||||
type ApiKeyType,
|
||||
type ApiKeyMetadata,
|
||||
|
||||
// Core functions
|
||||
storeApiKey,
|
||||
getApiKey,
|
||||
deleteApiKey,
|
||||
listApiKeyMetadata,
|
||||
updateApiKeyMetadata,
|
||||
hasApiKey,
|
||||
validateStoredApiKey,
|
||||
rotateApiKey,
|
||||
|
||||
// Utility functions
|
||||
validateApiKeyFormat,
|
||||
exportApiKeyConfig,
|
||||
isUsingKeychain,
|
||||
generateTestApiKey,
|
||||
} from './api-key-storage';
|
||||
|
||||
// Re-export encrypted chat storage
|
||||
export {
|
||||
initializeEncryptedChatStorage,
|
||||
saveConversations,
|
||||
loadConversations,
|
||||
clearAllChatData,
|
||||
exportEncryptedBackup,
|
||||
importEncryptedBackup,
|
||||
isEncryptedStorageActive,
|
||||
getStorageStats,
|
||||
rotateEncryptionKey,
|
||||
} from './encrypted-chat-storage';
|
||||
|
||||
// Re-export security audit
|
||||
export {
|
||||
// Core logging
|
||||
logSecurityEvent,
|
||||
logAuthEvent,
|
||||
logKeyEvent,
|
||||
logDataEvent,
|
||||
logSecurityViolation,
|
||||
logDecryptionFailure,
|
||||
logIntegrityFailure,
|
||||
logPermissionEvent,
|
||||
logSessionEvent,
|
||||
logSuspiciousActivity,
|
||||
logRateLimitEvent,
|
||||
|
||||
// Query functions
|
||||
getSecurityEvents,
|
||||
getSecurityEventsByType,
|
||||
getSecurityEventsBySeverity,
|
||||
getSecurityEventsByTimeRange,
|
||||
getRecentCriticalEvents,
|
||||
getSecurityEventsBySession,
|
||||
|
||||
// Report generation
|
||||
generateSecurityAuditReport,
|
||||
|
||||
// Maintenance
|
||||
clearSecurityAuditLog,
|
||||
exportSecurityEvents,
|
||||
importSecurityEvents,
|
||||
verifyAuditLogIntegrity,
|
||||
|
||||
// Session management
|
||||
getCurrentSessionId,
|
||||
setCurrentSessionId,
|
||||
setAuditEnabled,
|
||||
isAuditEnabledState,
|
||||
initializeSecurityAudit,
|
||||
shutdownSecurityAudit,
|
||||
} from './security-audit';
|
||||
|
||||
export type {
|
||||
SecurityEventType,
|
||||
SecurityEventSeverity,
|
||||
SecurityEvent,
|
||||
SecurityAuditReport,
|
||||
} from './security-audit';
|
||||
|
||||
// Re-export security utilities
|
||||
export {
|
||||
// HTML sanitization
|
||||
escapeHtml,
|
||||
unescapeHtml,
|
||||
sanitizeHtml,
|
||||
|
||||
// URL validation
|
||||
validateUrl,
|
||||
isSafeRedirectUrl,
|
||||
|
||||
// Path validation
|
||||
validatePath,
|
||||
|
||||
// Input validation
|
||||
isValidEmail,
|
||||
isValidUsername,
|
||||
validatePasswordStrength,
|
||||
sanitizeFilename,
|
||||
sanitizeJson,
|
||||
|
||||
// Rate limiting
|
||||
isRateLimited,
|
||||
resetRateLimit,
|
||||
getRemainingAttempts,
|
||||
|
||||
// CSP helpers
|
||||
generateCspNonce,
|
||||
buildCspHeader,
|
||||
DEFAULT_CSP_DIRECTIVES,
|
||||
|
||||
// Security checks
|
||||
checkSecurityHeaders,
|
||||
|
||||
// Random generation
|
||||
generateSecureToken,
|
||||
generateSecureId,
|
||||
} from './security-utils';
|
||||
|
||||
// ============================================================================
|
||||
// Security Initialization
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Initialize all security modules
|
||||
* Call this during application startup
|
||||
*/
|
||||
export async function initializeSecurity(sessionId?: string): Promise<void> {
|
||||
// Initialize security audit first
|
||||
const { initializeSecurityAudit } = await import('./security-audit');
|
||||
initializeSecurityAudit(sessionId);
|
||||
|
||||
// Initialize encrypted chat storage
|
||||
const { initializeEncryptedChatStorage } = await import('./encrypted-chat-storage');
|
||||
await initializeEncryptedChatStorage();
|
||||
|
||||
console.log('[Security] All security modules initialized');
|
||||
}
|
||||
|
||||
/**
|
||||
* Shutdown all security modules
|
||||
* Call this during application shutdown
|
||||
*/
|
||||
export async function shutdownSecurity(): Promise<void> {
|
||||
const { shutdownSecurityAudit } = await import('./security-audit');
|
||||
shutdownSecurityAudit();
|
||||
|
||||
const { clearKeyCache } = await import('./crypto-utils');
|
||||
clearKeyCache();
|
||||
|
||||
console.log('[Security] All security modules shut down');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a comprehensive security status report
|
||||
*/
|
||||
export async function getSecurityStatus(): Promise<{
|
||||
auditEnabled: boolean;
|
||||
keychainAvailable: boolean;
|
||||
chatStorageInitialized: boolean;
|
||||
storedApiKeys: number;
|
||||
recentEvents: number;
|
||||
criticalEvents: number;
|
||||
}> {
|
||||
const { isAuditEnabledState, getSecurityEventsBySeverity } = await import('./security-audit');
|
||||
const { isSecureStorageAvailable } = await import('./secure-storage');
|
||||
const { isEncryptedStorageActive: isChatStorageInitialized } = await import('./encrypted-chat-storage');
|
||||
const { listApiKeyMetadata } = await import('./api-key-storage');
|
||||
|
||||
const criticalEvents = getSecurityEventsBySeverity('critical').length;
|
||||
const errorEvents = getSecurityEventsBySeverity('error').length;
|
||||
|
||||
return {
|
||||
auditEnabled: isAuditEnabledState(),
|
||||
keychainAvailable: await isSecureStorageAvailable(),
|
||||
chatStorageInitialized: await isChatStorageInitialized(),
|
||||
storedApiKeys: (await listApiKeyMetadata()).length,
|
||||
recentEvents: criticalEvents + errorEvents,
|
||||
criticalEvents,
|
||||
};
|
||||
}
|
||||
729
desktop/src/lib/security-utils.ts
Normal file
729
desktop/src/lib/security-utils.ts
Normal file
@@ -0,0 +1,729 @@
|
||||
/**
|
||||
* Security Utilities for Input Validation and XSS Prevention
|
||||
*
|
||||
* Provides comprehensive input validation, sanitization, and XSS prevention
|
||||
* for the ZCLAW application.
|
||||
*
|
||||
* Security features:
|
||||
* - HTML sanitization
|
||||
* - URL validation
|
||||
* - Path traversal prevention
|
||||
* - Input validation helpers
|
||||
* - Content Security Policy helpers
|
||||
*/
|
||||
|
||||
// ============================================================================
|
||||
// HTML Sanitization
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* HTML entity encoding map
|
||||
*/
|
||||
const HTML_ENTITIES: Record<string, string> = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": ''',
|
||||
'/': '/',
|
||||
'`': '`',
|
||||
'=': '=',
|
||||
};
|
||||
|
||||
/**
|
||||
* Escape HTML entities in a string
|
||||
* Prevents XSS attacks by encoding dangerous characters
|
||||
*
|
||||
* @param input - The string to escape
|
||||
* @returns The escaped string
|
||||
*/
|
||||
export function escapeHtml(input: string): string {
|
||||
if (typeof input !== 'string') {
|
||||
return '';
|
||||
}
|
||||
return input.replace(/[&<>"'`=\/]/g, char => HTML_ENTITIES[char] || char);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unescape HTML entities in a string
|
||||
*
|
||||
* @param input - The string to unescape
|
||||
* @returns The unescaped string
|
||||
*/
|
||||
export function unescapeHtml(input: string): string {
|
||||
if (typeof input !== 'string') {
|
||||
return '';
|
||||
}
|
||||
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.innerHTML = input;
|
||||
return textarea.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Allowed HTML tags for safe rendering
|
||||
*/
|
||||
const ALLOWED_TAGS = new Set([
|
||||
'p', 'br', 'b', 'i', 'u', 'strong', 'em',
|
||||
'ul', 'ol', 'li', 'blockquote', 'code', 'pre',
|
||||
'a', 'span', 'div', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Allowed HTML attributes
|
||||
*/
|
||||
const ALLOWED_ATTRIBUTES = new Set([
|
||||
'href', 'title', 'class', 'id', 'target', 'rel',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Sanitize HTML content for safe rendering
|
||||
* Removes dangerous tags and attributes while preserving safe content
|
||||
*
|
||||
* @param html - The HTML string to sanitize
|
||||
* @param options - Sanitization options
|
||||
* @returns The sanitized HTML
|
||||
*/
|
||||
export function sanitizeHtml(
|
||||
html: string,
|
||||
options: {
|
||||
allowedTags?: string[];
|
||||
allowedAttributes?: string[];
|
||||
allowDataAttributes?: boolean;
|
||||
} = {}
|
||||
): string {
|
||||
if (typeof html !== 'string') {
|
||||
return '';
|
||||
}
|
||||
|
||||
const allowedTags = new Set(options.allowedTags || ALLOWED_TAGS);
|
||||
const allowedAttributes = new Set(options.allowedAttributes || ALLOWED_ATTRIBUTES);
|
||||
|
||||
// Create a temporary container
|
||||
const container = document.createElement('div');
|
||||
container.innerHTML = html;
|
||||
|
||||
// Recursively clean elements
|
||||
function cleanElement(element: Element): void {
|
||||
// Remove script tags entirely
|
||||
if (element.tagName.toLowerCase() === 'script') {
|
||||
element.remove();
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove style tags entirely
|
||||
if (element.tagName.toLowerCase() === 'style') {
|
||||
element.remove();
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove event handlers and dangerous attributes
|
||||
const attributes = Array.from(element.attributes);
|
||||
for (const attr of attributes) {
|
||||
const attrName = attr.name.toLowerCase();
|
||||
|
||||
// Remove event handlers (onclick, onload, etc.)
|
||||
if (attrName.startsWith('on')) {
|
||||
element.removeAttribute(attr.name);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Remove javascript: URLs
|
||||
if (attrName === 'href' || attrName === 'src') {
|
||||
const value = attr.value.toLowerCase().trim();
|
||||
if (value.startsWith('javascript:') || value.startsWith('data:text/html')) {
|
||||
element.removeAttribute(attr.name);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Remove data attributes if not allowed
|
||||
if (attrName.startsWith('data-') && !options.allowDataAttributes) {
|
||||
element.removeAttribute(attr.name);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Remove non-allowed attributes
|
||||
if (!allowedAttributes.has(attrName)) {
|
||||
element.removeAttribute(attr.name);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove non-allowed tags (but keep their content)
|
||||
if (!allowedTags.has(element.tagName.toLowerCase())) {
|
||||
const parent = element.parentNode;
|
||||
while (element.firstChild) {
|
||||
parent?.insertBefore(element.firstChild, element);
|
||||
}
|
||||
parent?.removeChild(element);
|
||||
return;
|
||||
}
|
||||
|
||||
// Recursively clean child elements
|
||||
Array.from(element.children).forEach(cleanElement);
|
||||
}
|
||||
|
||||
// Clean all elements
|
||||
Array.from(container.children).forEach(cleanElement);
|
||||
|
||||
return container.innerHTML;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// URL Validation
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Allowed URL schemes
|
||||
*/
|
||||
const ALLOWED_SCHEMES = new Set([
|
||||
'http', 'https', 'mailto', 'tel', 'ftp', 'file',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Validate and sanitize a URL
|
||||
*
|
||||
* @param url - The URL to validate
|
||||
* @param options - Validation options
|
||||
* @returns The validated URL or null if invalid
|
||||
*/
|
||||
export function validateUrl(
|
||||
url: string,
|
||||
options: {
|
||||
allowedSchemes?: string[];
|
||||
allowLocalhost?: boolean;
|
||||
allowPrivateIp?: boolean;
|
||||
maxLength?: number;
|
||||
} = {}
|
||||
): string | null {
|
||||
if (typeof url !== 'string' || url.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const maxLength = options.maxLength || 2048;
|
||||
if (url.length > maxLength) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
|
||||
// Check scheme
|
||||
const allowedSchemes = new Set(options.allowedSchemes || ALLOWED_SCHEMES);
|
||||
if (!allowedSchemes.has(parsed.protocol.replace(':', ''))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check for localhost
|
||||
if (!options.allowLocalhost) {
|
||||
if (parsed.hostname === 'localhost' ||
|
||||
parsed.hostname === '127.0.0.1' ||
|
||||
parsed.hostname === '[::1]') {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for private IP ranges
|
||||
if (!options.allowPrivateIp) {
|
||||
const privateIpRegex = /^(10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.)/;
|
||||
if (privateIpRegex.test(parsed.hostname)) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return parsed.toString();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a URL is safe for redirect
|
||||
* Prevents open redirect vulnerabilities
|
||||
*
|
||||
* @param url - The URL to check
|
||||
* @returns True if the URL is safe for redirect
|
||||
*/
|
||||
export function isSafeRedirectUrl(url: string): boolean {
|
||||
if (typeof url !== 'string' || url.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Relative URLs are generally safe
|
||||
if (url.startsWith('/') && !url.startsWith('//')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for javascript: protocol
|
||||
const lowerUrl = url.toLowerCase().trim();
|
||||
if (lowerUrl.startsWith('javascript:')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for data: protocol
|
||||
if (lowerUrl.startsWith('data:')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Validate as absolute URL
|
||||
const validated = validateUrl(url, { allowLocalhost: false });
|
||||
return validated !== null;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Path Validation
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Validate a file path to prevent path traversal attacks
|
||||
*
|
||||
* @param path - The path to validate
|
||||
* @param options - Validation options
|
||||
* @returns The validated path or null if invalid
|
||||
*/
|
||||
export function validatePath(
|
||||
path: string,
|
||||
options: {
|
||||
allowAbsolute?: boolean;
|
||||
allowParentDir?: boolean;
|
||||
maxLength?: number;
|
||||
allowedExtensions?: string[];
|
||||
baseDir?: string;
|
||||
} = {}
|
||||
): string | null {
|
||||
if (typeof path !== 'string' || path.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const maxLength = options.maxLength || 4096;
|
||||
if (path.length > maxLength) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Normalize path separators
|
||||
let normalized = path.replace(/\\/g, '/');
|
||||
|
||||
// Check for null bytes
|
||||
if (normalized.includes('\0')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check for path traversal
|
||||
if (!options.allowParentDir) {
|
||||
if (normalized.includes('..') || normalized.includes('./')) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for absolute paths
|
||||
if (!options.allowAbsolute) {
|
||||
if (normalized.startsWith('/') || /^[a-zA-Z]:/.test(normalized)) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Check extensions
|
||||
if (options.allowedExtensions && options.allowedExtensions.length > 0) {
|
||||
const ext = normalized.split('.').pop()?.toLowerCase();
|
||||
if (!ext || !options.allowedExtensions.includes(ext)) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// If baseDir is specified, ensure path is within it
|
||||
if (options.baseDir) {
|
||||
const baseDir = options.baseDir.replace(/\\/g, '/').replace(/\/$/, '');
|
||||
if (!normalized.startsWith(baseDir)) {
|
||||
// Try to resolve relative to baseDir
|
||||
try {
|
||||
const resolved = new URL(normalized, `file://${baseDir}/`).pathname;
|
||||
if (!resolved.startsWith(baseDir)) {
|
||||
return null;
|
||||
}
|
||||
normalized = resolved;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Input Validation Helpers
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Validate an email address
|
||||
*
|
||||
* @param email - The email to validate
|
||||
* @returns True if valid
|
||||
*/
|
||||
export function isValidEmail(email: string): boolean {
|
||||
if (typeof email !== 'string' || email.length === 0 || email.length > 254) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// RFC 5322 compliant regex (simplified)
|
||||
const emailRegex = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
|
||||
return emailRegex.test(email);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a username
|
||||
*
|
||||
* @param username - The username to validate
|
||||
* @param options - Validation options
|
||||
* @returns True if valid
|
||||
*/
|
||||
export function isValidUsername(
|
||||
username: string,
|
||||
options: {
|
||||
minLength?: number;
|
||||
maxLength?: number;
|
||||
allowedChars?: RegExp;
|
||||
} = {}
|
||||
): boolean {
|
||||
const minLength = options.minLength || 3;
|
||||
const maxLength = options.maxLength || 30;
|
||||
const allowedChars = options.allowedChars || /^[a-zA-Z0-9_-]+$/;
|
||||
|
||||
if (typeof username !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (username.length < minLength || username.length > maxLength) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return allowedChars.test(username);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a password strength
|
||||
*
|
||||
* @param password - The password to validate
|
||||
* @param options - Validation options
|
||||
* @returns Validation result with strength score
|
||||
*/
|
||||
export function validatePasswordStrength(
|
||||
password: string,
|
||||
options: {
|
||||
minLength?: number;
|
||||
requireUppercase?: boolean;
|
||||
requireLowercase?: boolean;
|
||||
requireNumber?: boolean;
|
||||
requireSpecial?: boolean;
|
||||
maxLength?: number;
|
||||
} = {}
|
||||
): {
|
||||
valid: boolean;
|
||||
score: number;
|
||||
issues: string[];
|
||||
} {
|
||||
const minLength = options.minLength || 8;
|
||||
const maxLength = options.maxLength || 128;
|
||||
const issues: string[] = [];
|
||||
let score = 0;
|
||||
|
||||
if (typeof password !== 'string') {
|
||||
return { valid: false, score: 0, issues: ['Password must be a string'] };
|
||||
}
|
||||
|
||||
if (password.length < minLength) {
|
||||
issues.push(`Password must be at least ${minLength} characters`);
|
||||
} else {
|
||||
score += Math.min(password.length / 8, 3) * 10;
|
||||
}
|
||||
|
||||
if (password.length > maxLength) {
|
||||
issues.push(`Password must be at most ${maxLength} characters`);
|
||||
}
|
||||
|
||||
if (options.requireUppercase !== false && !/[A-Z]/.test(password)) {
|
||||
issues.push('Password must contain an uppercase letter');
|
||||
} else if (/[A-Z]/.test(password)) {
|
||||
score += 10;
|
||||
}
|
||||
|
||||
if (options.requireLowercase !== false && !/[a-z]/.test(password)) {
|
||||
issues.push('Password must contain a lowercase letter');
|
||||
} else if (/[a-z]/.test(password)) {
|
||||
score += 10;
|
||||
}
|
||||
|
||||
if (options.requireNumber !== false && !/[0-9]/.test(password)) {
|
||||
issues.push('Password must contain a number');
|
||||
} else if (/[0-9]/.test(password)) {
|
||||
score += 10;
|
||||
}
|
||||
|
||||
if (options.requireSpecial !== false && !/[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(password)) {
|
||||
issues.push('Password must contain a special character');
|
||||
} else if (/[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(password)) {
|
||||
score += 15;
|
||||
}
|
||||
|
||||
// Check for common patterns
|
||||
const commonPatterns = [
|
||||
/123/,
|
||||
/abc/i,
|
||||
/qwe/i,
|
||||
/password/i,
|
||||
/admin/i,
|
||||
/letmein/i,
|
||||
];
|
||||
|
||||
for (const pattern of commonPatterns) {
|
||||
if (pattern.test(password)) {
|
||||
issues.push('Password contains a common pattern');
|
||||
score -= 10;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
valid: issues.length === 0,
|
||||
score: Math.max(0, Math.min(100, score)),
|
||||
issues,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize a filename
|
||||
*
|
||||
* @param filename - The filename to sanitize
|
||||
* @returns The sanitized filename
|
||||
*/
|
||||
export function sanitizeFilename(filename: string): string {
|
||||
if (typeof filename !== 'string') {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Remove path separators
|
||||
let sanitized = filename.replace(/[\/\\]/g, '_');
|
||||
|
||||
// Remove null bytes
|
||||
sanitized = sanitized.replace(/\0/g, '');
|
||||
|
||||
// Remove control characters
|
||||
sanitized = sanitized.replace(/[\x00-\x1f\x7f]/g, '');
|
||||
|
||||
// Remove dangerous characters
|
||||
sanitized = sanitized.replace(/[<>:"|?*]/g, '_');
|
||||
|
||||
// Trim whitespace and dots
|
||||
sanitized = sanitized.trim().replace(/^\.+|\.+$/g, '');
|
||||
|
||||
// Limit length
|
||||
if (sanitized.length > 255) {
|
||||
const ext = sanitized.split('.').pop();
|
||||
const name = sanitized.slice(0, -(ext?.length || 0) - 1);
|
||||
sanitized = name.slice(0, 250 - (ext?.length || 0)) + (ext ? `.${ext}` : '');
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize JSON input
|
||||
* Prevents prototype pollution and other JSON-based attacks
|
||||
*
|
||||
* @param json - The JSON string to sanitize
|
||||
* @returns The parsed and sanitized object or null if invalid
|
||||
*/
|
||||
export function sanitizeJson<T = unknown>(json: string): T | null {
|
||||
if (typeof json !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(json);
|
||||
|
||||
// Check for prototype pollution
|
||||
if (typeof parsed === 'object' && parsed !== null) {
|
||||
const dangerousKeys = ['__proto__', 'constructor', 'prototype'];
|
||||
for (const key of dangerousKeys) {
|
||||
if (key in parsed) {
|
||||
delete (parsed as Record<string, unknown>)[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return parsed as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Rate Limiting
|
||||
// ============================================================================
|
||||
|
||||
interface RateLimitEntry {
|
||||
count: number;
|
||||
resetAt: number;
|
||||
}
|
||||
|
||||
const rateLimitStore = new Map<string, RateLimitEntry>();
|
||||
|
||||
/**
|
||||
* Check if an action is rate limited
|
||||
*
|
||||
* @param key - The rate limit key (e.g., 'api:username')
|
||||
* @param maxAttempts - Maximum attempts allowed
|
||||
* @param windowMs - Time window in milliseconds
|
||||
* @returns True if rate limited (should block), false otherwise
|
||||
*/
|
||||
export function isRateLimited(
|
||||
key: string,
|
||||
maxAttempts: number,
|
||||
windowMs: number
|
||||
): boolean {
|
||||
const now = Date.now();
|
||||
const entry = rateLimitStore.get(key);
|
||||
|
||||
if (!entry || now > entry.resetAt) {
|
||||
rateLimitStore.set(key, {
|
||||
count: 1,
|
||||
resetAt: now + windowMs,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
if (entry.count >= maxAttempts) {
|
||||
return true;
|
||||
}
|
||||
|
||||
entry.count++;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset rate limit for a key
|
||||
*
|
||||
* @param key - The rate limit key to reset
|
||||
*/
|
||||
export function resetRateLimit(key: string): void {
|
||||
rateLimitStore.delete(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get remaining attempts for a rate-limited action
|
||||
*
|
||||
* @param key - The rate limit key
|
||||
* @param maxAttempts - Maximum attempts allowed
|
||||
* @returns Number of remaining attempts
|
||||
*/
|
||||
export function getRemainingAttempts(key: string, maxAttempts: number): number {
|
||||
const entry = rateLimitStore.get(key);
|
||||
if (!entry || Date.now() > entry.resetAt) {
|
||||
return maxAttempts;
|
||||
}
|
||||
return Math.max(0, maxAttempts - entry.count);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Content Security Policy Helpers
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Generate a nonce for CSP
|
||||
*
|
||||
* @returns A base64-encoded nonce
|
||||
*/
|
||||
export function generateCspNonce(): string {
|
||||
const array = crypto.getRandomValues(new Uint8Array(16));
|
||||
return btoa(String.fromCharCode(...array));
|
||||
}
|
||||
|
||||
/**
|
||||
* CSP directives for secure applications
|
||||
*/
|
||||
export const DEFAULT_CSP_DIRECTIVES = {
|
||||
'default-src': "'self'",
|
||||
'script-src': "'self' 'unsafe-inline'", // Note: unsafe-inline should be avoided in production
|
||||
'style-src': "'self' 'unsafe-inline'",
|
||||
'img-src': "'self' data: https:",
|
||||
'font-src': "'self'",
|
||||
'connect-src': "'self' ws: wss:",
|
||||
'frame-ancestors': "'none'",
|
||||
'base-uri': "'self'",
|
||||
'form-action': "'self'",
|
||||
};
|
||||
|
||||
/**
|
||||
* Build a Content Security Policy header value
|
||||
*
|
||||
* @param directives - CSP directives
|
||||
* @returns The CSP header value
|
||||
*/
|
||||
export function buildCspHeader(
|
||||
directives: Partial<typeof DEFAULT_CSP_DIRECTIVES> = DEFAULT_CSP_DIRECTIVES
|
||||
): string {
|
||||
const merged = { ...DEFAULT_CSP_DIRECTIVES, ...directives };
|
||||
return Object.entries(merged)
|
||||
.map(([key, value]) => `${key} ${value}`)
|
||||
.join('; ');
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Security Headers Validation
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Check if security headers are properly set (for browser environments)
|
||||
*/
|
||||
export function checkSecurityHeaders(): {
|
||||
secure: boolean;
|
||||
issues: string[];
|
||||
} {
|
||||
const issues: string[] = [];
|
||||
|
||||
// Check if running over HTTPS
|
||||
if (typeof window !== 'undefined') {
|
||||
if (window.location.protocol !== 'https:' && window.location.hostname !== 'localhost') {
|
||||
issues.push('Application is not running over HTTPS');
|
||||
}
|
||||
|
||||
// Check for mixed content
|
||||
if (window.location.protocol === 'https:') {
|
||||
// This would require DOM inspection to detect mixed content
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
secure: issues.length === 0,
|
||||
issues,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Secure Random Generation
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Generate a secure random token
|
||||
*
|
||||
* @param length - Token length in bytes
|
||||
* @returns Hex-encoded random token
|
||||
*/
|
||||
export function generateSecureToken(length: number = 32): string {
|
||||
const array = crypto.getRandomValues(new Uint8Array(length));
|
||||
return Array.from(array)
|
||||
.map(b => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a secure random ID
|
||||
*
|
||||
* @param prefix - Optional prefix
|
||||
* @returns A secure random ID
|
||||
*/
|
||||
export function generateSecureId(prefix: string = ''): string {
|
||||
const timestamp = Date.now().toString(36);
|
||||
const random = generateSecureToken(8);
|
||||
return prefix ? `${prefix}_${timestamp}_${random}` : `${timestamp}_${random}`;
|
||||
}
|
||||
@@ -3,11 +3,38 @@ import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
import './index.css';
|
||||
import { ToastProvider } from './components/ui/Toast';
|
||||
import { GlobalErrorBoundary } from './components/ui/ErrorBoundary';
|
||||
|
||||
// Global error handler for uncaught errors
|
||||
const handleGlobalError = (error: Error, errorInfo: React.ErrorInfo) => {
|
||||
console.error('[GlobalErrorHandler] Uncaught error:', error);
|
||||
console.error('[GlobalErrorHandler] Component stack:', errorInfo.componentStack);
|
||||
|
||||
// In production, you could send this to an error reporting service
|
||||
// e.g., Sentry, LogRocket, etc.
|
||||
if (import.meta.env.PROD) {
|
||||
// sendToErrorReportingService(error, errorInfo);
|
||||
}
|
||||
};
|
||||
|
||||
// Global reset handler - reload the page
|
||||
const handleGlobalReset = () => {
|
||||
console.log('[GlobalErrorHandler] Resetting application...');
|
||||
// Clear any cached state
|
||||
localStorage.removeItem('app-state');
|
||||
sessionStorage.clear();
|
||||
};
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<ToastProvider>
|
||||
<App />
|
||||
</ToastProvider>
|
||||
<GlobalErrorBoundary
|
||||
onError={handleGlobalError}
|
||||
onReset={handleGlobalReset}
|
||||
showConnectionStatus={true}
|
||||
>
|
||||
<ToastProvider>
|
||||
<App />
|
||||
</ToastProvider>
|
||||
</GlobalErrorBoundary>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { create } from 'zustand';
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import { getGatewayClient, AgentStreamDelta } from '../lib/gateway-client';
|
||||
import { intelligenceClient } from '../lib/intelligence-client';
|
||||
import { getMemoryExtractor } from '../lib/memory-extractor';
|
||||
import { getAgentSwarm } from '../lib/agent-swarm';
|
||||
import { getSkillDiscovery } from '../lib/skill-discovery';
|
||||
import { useOfflineStore, isOffline } from './offlineStore';
|
||||
import { useConnectionStore } from './connectionStore';
|
||||
|
||||
export interface MessageFile {
|
||||
name: string;
|
||||
@@ -21,7 +23,7 @@ export interface CodeBlock {
|
||||
|
||||
export interface Message {
|
||||
id: string;
|
||||
role: 'user' | 'assistant' | 'tool' | 'hand' | 'workflow';
|
||||
role: 'user' | 'assistant' | 'tool' | 'hand' | 'workflow' | 'system';
|
||||
content: string;
|
||||
timestamp: Date;
|
||||
runId?: string;
|
||||
@@ -77,11 +79,13 @@ interface ChatState {
|
||||
agents: Agent[];
|
||||
currentAgent: Agent | null;
|
||||
isStreaming: boolean;
|
||||
isLoading: boolean;
|
||||
currentModel: string;
|
||||
sessionKey: string | null;
|
||||
|
||||
addMessage: (message: Message) => void;
|
||||
updateMessage: (id: string, updates: Partial<Message>) => void;
|
||||
setIsLoading: (loading: boolean) => void;
|
||||
setCurrentAgent: (agent: Agent) => void;
|
||||
syncAgents: (profiles: AgentProfileLike[]) => void;
|
||||
setCurrentModel: (model: string) => void;
|
||||
@@ -185,6 +189,7 @@ export const useChatStore = create<ChatState>()(
|
||||
agents: [DEFAULT_AGENT],
|
||||
currentAgent: DEFAULT_AGENT,
|
||||
isStreaming: false,
|
||||
isLoading: false,
|
||||
currentModel: 'glm-5',
|
||||
sessionKey: null,
|
||||
|
||||
@@ -198,6 +203,8 @@ export const useChatStore = create<ChatState>()(
|
||||
),
|
||||
})),
|
||||
|
||||
setIsLoading: (loading) => set({ isLoading: loading }),
|
||||
|
||||
setCurrentAgent: (agent) =>
|
||||
set((state) => {
|
||||
if (state.currentAgent?.id === agent.id) {
|
||||
@@ -295,6 +302,32 @@ export const useChatStore = create<ChatState>()(
|
||||
const effectiveAgentId = resolveGatewayAgentId(currentAgent);
|
||||
const agentId = currentAgent?.id || 'zclaw-main';
|
||||
|
||||
// Check if offline - queue message instead of sending
|
||||
if (isOffline()) {
|
||||
const { queueMessage } = useOfflineStore.getState();
|
||||
const queueId = queueMessage(content, effectiveAgentId, effectiveSessionKey);
|
||||
console.log(`[Chat] Offline - message queued: ${queueId}`);
|
||||
|
||||
// Show a system message about offline queueing
|
||||
const systemMsg: Message = {
|
||||
id: `system_${Date.now()}`,
|
||||
role: 'system',
|
||||
content: `后端服务不可用,消息已保存到本地队列。恢复连接后将自动发送。`,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
addMessage(systemMsg);
|
||||
|
||||
// Add user message for display
|
||||
const userMsg: Message = {
|
||||
id: `user_${Date.now()}`,
|
||||
role: 'user',
|
||||
content,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
addMessage(userMsg);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check context compaction threshold before adding new message
|
||||
try {
|
||||
const messages = get().messages.map(m => ({ role: m.role, content: m.content }));
|
||||
@@ -368,134 +401,107 @@ export const useChatStore = create<ChatState>()(
|
||||
try {
|
||||
const client = getGatewayClient();
|
||||
|
||||
// Try streaming first (OpenFang WebSocket)
|
||||
// Note: onDelta is empty - stream updates handled by initStreamListener to avoid duplication
|
||||
if (client.getState() === 'connected') {
|
||||
const { runId } = await client.chatStream(
|
||||
enhancedContent,
|
||||
{
|
||||
onDelta: () => { /* Handled by initStreamListener to prevent duplication */ },
|
||||
onTool: (tool: string, input: string, output: string) => {
|
||||
const toolMsg: Message = {
|
||||
id: `tool_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`,
|
||||
role: 'tool',
|
||||
content: output || input,
|
||||
timestamp: new Date(),
|
||||
runId,
|
||||
toolName: tool,
|
||||
toolInput: input,
|
||||
toolOutput: output,
|
||||
};
|
||||
set((state) => ({ messages: [...state.messages, toolMsg] }));
|
||||
},
|
||||
onHand: (name: string, status: string, result?: unknown) => {
|
||||
const handMsg: Message = {
|
||||
id: `hand_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`,
|
||||
role: 'hand',
|
||||
content: result
|
||||
? (typeof result === 'string' ? result : JSON.stringify(result, null, 2))
|
||||
: `Hand: ${name} - ${status}`,
|
||||
timestamp: new Date(),
|
||||
runId,
|
||||
handName: name,
|
||||
handStatus: status,
|
||||
handResult: result,
|
||||
};
|
||||
set((state) => ({ messages: [...state.messages, handMsg] }));
|
||||
},
|
||||
onComplete: () => {
|
||||
const state = get();
|
||||
// Check connection state first
|
||||
const connectionState = useConnectionStore.getState().connectionState;
|
||||
|
||||
// Save conversation to persist across refresh
|
||||
const conversations = upsertActiveConversation([...state.conversations], state);
|
||||
const currentConvId = state.currentConversationId || conversations[0]?.id;
|
||||
|
||||
set({
|
||||
isStreaming: false,
|
||||
conversations,
|
||||
currentConversationId: currentConvId,
|
||||
messages: state.messages.map((m) =>
|
||||
m.id === assistantId ? { ...m, streaming: false, runId } : m
|
||||
),
|
||||
});
|
||||
|
||||
// Async memory extraction after stream completes
|
||||
const msgs = get().messages
|
||||
.filter(m => m.role === 'user' || m.role === 'assistant')
|
||||
.map(m => ({ role: m.role, content: m.content }));
|
||||
getMemoryExtractor().extractFromConversation(msgs, agentId, get().currentConversationId ?? undefined).catch(err =>
|
||||
console.warn('[Chat] Memory extraction failed:', err)
|
||||
);
|
||||
// Track conversation for reflection trigger
|
||||
intelligenceClient.reflection.recordConversation().catch(err =>
|
||||
console.warn('[Chat] Recording conversation failed:', err)
|
||||
);
|
||||
intelligenceClient.reflection.shouldReflect().then(shouldReflect => {
|
||||
if (shouldReflect) {
|
||||
intelligenceClient.reflection.reflect(agentId, []).catch(err =>
|
||||
console.warn('[Chat] Reflection failed:', err)
|
||||
);
|
||||
}
|
||||
});
|
||||
},
|
||||
onError: (error: string) => {
|
||||
set((state) => ({
|
||||
isStreaming: false,
|
||||
messages: state.messages.map((m) =>
|
||||
m.id === assistantId
|
||||
? { ...m, content: `⚠️ ${error}`, streaming: false, error }
|
||||
: m
|
||||
),
|
||||
}));
|
||||
},
|
||||
},
|
||||
{
|
||||
sessionKey: effectiveSessionKey,
|
||||
agentId: effectiveAgentId,
|
||||
}
|
||||
);
|
||||
|
||||
if (!sessionKey) {
|
||||
set({ sessionKey: effectiveSessionKey });
|
||||
}
|
||||
|
||||
// Store runId on the message for correlation
|
||||
set((state) => ({
|
||||
messages: state.messages.map((m) =>
|
||||
m.id === assistantId ? { ...m, runId } : m
|
||||
),
|
||||
}));
|
||||
return;
|
||||
if (connectionState !== 'connected') {
|
||||
// Connection lost during send - update error
|
||||
throw new Error(`Not connected (state: ${connectionState})`);
|
||||
}
|
||||
|
||||
// Fallback to REST API (non-streaming)
|
||||
const result = await client.chat(enhancedContent, {
|
||||
sessionKey: effectiveSessionKey,
|
||||
agentId: effectiveAgentId,
|
||||
});
|
||||
// Try streaming first (OpenFang WebSocket)
|
||||
const { runId } = await client.chatStream(
|
||||
enhancedContent,
|
||||
{
|
||||
onDelta: () => { /* Handled by initStreamListener to prevent duplication */ },
|
||||
onTool: (tool: string, input: string, output: string) => {
|
||||
const toolMsg: Message = {
|
||||
id: `tool_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`,
|
||||
role: 'tool',
|
||||
content: output || input,
|
||||
timestamp: new Date(),
|
||||
runId,
|
||||
toolName: tool,
|
||||
toolInput: input,
|
||||
toolOutput: output,
|
||||
};
|
||||
set((state) => ({ messages: [...state.messages, toolMsg] }));
|
||||
},
|
||||
onHand: (name: string, status: string, result?: unknown) => {
|
||||
const handMsg: Message = {
|
||||
id: `hand_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`,
|
||||
role: 'hand',
|
||||
content: result
|
||||
? (typeof result === 'string' ? result : JSON.stringify(result, null, 2))
|
||||
: `Hand: ${name} - ${status}`,
|
||||
timestamp: new Date(),
|
||||
runId,
|
||||
handName: name,
|
||||
handStatus: status,
|
||||
handResult: result,
|
||||
};
|
||||
set((state) => ({ messages: [...state.messages, handMsg] }));
|
||||
},
|
||||
onComplete: () => {
|
||||
const state = get();
|
||||
|
||||
// Save conversation to persist across refresh
|
||||
const conversations = upsertActiveConversation([...state.conversations], state);
|
||||
const currentConvId = state.currentConversationId || conversations[0]?.id;
|
||||
|
||||
set({
|
||||
isStreaming: false,
|
||||
conversations,
|
||||
currentConversationId: currentConvId,
|
||||
messages: state.messages.map((m) =>
|
||||
m.id === assistantId ? { ...m, streaming: false, runId } : m
|
||||
),
|
||||
});
|
||||
|
||||
// Async memory extraction after stream completes
|
||||
const msgs = get().messages
|
||||
.filter(m => m.role === 'user' || m.role === 'assistant')
|
||||
.map(m => ({ role: m.role, content: m.content }));
|
||||
getMemoryExtractor().extractFromConversation(msgs, agentId, get().currentConversationId ?? undefined).catch(err => {
|
||||
console.warn('[Chat] Memory extraction failed:', err);
|
||||
});
|
||||
// Track conversation for reflection trigger
|
||||
intelligenceClient.reflection.recordConversation().catch(err => {
|
||||
console.warn('[Chat] Recording conversation failed:', err);
|
||||
});
|
||||
intelligenceClient.reflection.shouldReflect().then(shouldReflect => {
|
||||
if (shouldReflect) {
|
||||
intelligenceClient.reflection.reflect(agentId, []).catch(err => {
|
||||
console.warn('[Chat] Reflection failed:', err);
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
onError: (error: string) => {
|
||||
set((state) => ({
|
||||
isStreaming: false,
|
||||
messages: state.messages.map((m) =>
|
||||
m.id === assistantId
|
||||
? { ...m, content: `⚠️ ${error}`, streaming: false, error }
|
||||
: m
|
||||
),
|
||||
}));
|
||||
},
|
||||
},
|
||||
{
|
||||
sessionKey: effectiveSessionKey,
|
||||
agentId: effectiveAgentId,
|
||||
}
|
||||
);
|
||||
|
||||
if (!sessionKey) {
|
||||
set({ sessionKey: effectiveSessionKey });
|
||||
}
|
||||
|
||||
// OpenFang returns response directly (no WebSocket streaming)
|
||||
if (result.response) {
|
||||
set((state) => ({
|
||||
isStreaming: false,
|
||||
messages: state.messages.map((m) =>
|
||||
m.id === assistantId
|
||||
? { ...m, content: result.response || '', streaming: false }
|
||||
: m
|
||||
),
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
// The actual streaming content comes via the 'agent' event listener
|
||||
// set in initStreamListener(). The runId links events to this message.
|
||||
// Store runId on the message for correlation
|
||||
set((state) => ({
|
||||
messages: state.messages.map((m) =>
|
||||
m.id === assistantId ? { ...m, runId: result.runId } : m
|
||||
m.id === assistantId ? { ...m, runId } : m
|
||||
),
|
||||
}));
|
||||
} catch (err: unknown) {
|
||||
@@ -686,3 +692,9 @@ export const useChatStore = create<ChatState>()(
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Dev-only: Expose chatStore to window for E2E testing
|
||||
if (import.meta.env.DEV && typeof window !== 'undefined') {
|
||||
(window as any).__ZCLAW_STORES__ = (window as any).__ZCLAW_STORES__ || {};
|
||||
(window as any).__ZCLAW_STORES__.chat = useChatStore;
|
||||
}
|
||||
|
||||
@@ -347,5 +347,11 @@ if (import.meta.env.DEV && typeof window !== 'undefined') {
|
||||
(window as any).__ZCLAW_STORES__.config = useConfigStore;
|
||||
(window as any).__ZCLAW_STORES__.security = useSecurityStore;
|
||||
(window as any).__ZCLAW_STORES__.session = useSessionStore;
|
||||
// Dynamically import chatStore to avoid circular dependency
|
||||
import('./chatStore').then(({ useChatStore }) => {
|
||||
(window as any).__ZCLAW_STORES__.chat = useChatStore;
|
||||
}).catch(() => {
|
||||
// Ignore if chatStore is not available
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
358
desktop/src/store/offlineStore.ts
Normal file
358
desktop/src/store/offlineStore.ts
Normal file
@@ -0,0 +1,358 @@
|
||||
/**
|
||||
* Offline Store
|
||||
*
|
||||
* Manages offline state, message queue, and reconnection logic.
|
||||
* Provides graceful degradation when backend is unavailable.
|
||||
*/
|
||||
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import { useConnectionStore, getConnectionState } from './connectionStore';
|
||||
|
||||
// === Types ===
|
||||
|
||||
export interface QueuedMessage {
|
||||
id: string;
|
||||
content: string;
|
||||
agentId?: string;
|
||||
sessionKey?: string;
|
||||
timestamp: number;
|
||||
retryCount: number;
|
||||
lastError?: string;
|
||||
status: 'pending' | 'sending' | 'failed' | 'sent';
|
||||
}
|
||||
|
||||
export interface OfflineState {
|
||||
isOffline: boolean;
|
||||
isReconnecting: boolean;
|
||||
reconnectAttempt: number;
|
||||
nextReconnectDelay: number;
|
||||
lastOnlineTime: number | null;
|
||||
queuedMessages: QueuedMessage[];
|
||||
maxRetryCount: number;
|
||||
maxQueueSize: number;
|
||||
}
|
||||
|
||||
export interface OfflineActions {
|
||||
// State management
|
||||
setOffline: (offline: boolean) => void;
|
||||
setReconnecting: (reconnecting: boolean, attempt?: number) => void;
|
||||
|
||||
// Message queue operations
|
||||
queueMessage: (content: string, agentId?: string, sessionKey?: string) => string;
|
||||
updateMessageStatus: (id: string, status: QueuedMessage['status'], error?: string) => void;
|
||||
removeMessage: (id: string) => void;
|
||||
clearQueue: () => void;
|
||||
retryAllMessages: () => Promise<void>;
|
||||
|
||||
// Reconnection
|
||||
scheduleReconnect: () => void;
|
||||
cancelReconnect: () => void;
|
||||
attemptReconnect: () => Promise<boolean>;
|
||||
|
||||
// Getters
|
||||
getPendingMessages: () => QueuedMessage[];
|
||||
hasPendingMessages: () => boolean;
|
||||
}
|
||||
|
||||
export type OfflineStore = OfflineState & OfflineActions;
|
||||
|
||||
// === Constants ===
|
||||
|
||||
const INITIAL_RECONNECT_DELAY = 1000; // 1 second
|
||||
const MAX_RECONNECT_DELAY = 60000; // 60 seconds
|
||||
const RECONNECT_BACKOFF_FACTOR = 1.5;
|
||||
const MAX_RETRY_COUNT = 5;
|
||||
const MAX_QUEUE_SIZE = 100;
|
||||
|
||||
// === Helper Functions ===
|
||||
|
||||
function calculateNextDelay(currentDelay: number): number {
|
||||
return Math.min(
|
||||
currentDelay * RECONNECT_BACKOFF_FACTOR,
|
||||
MAX_RECONNECT_DELAY
|
||||
);
|
||||
}
|
||||
|
||||
function generateMessageId(): string {
|
||||
return `queued_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
// === Store Implementation ===
|
||||
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let healthCheckInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
export const useOfflineStore = create<OfflineStore>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
// === Initial State ===
|
||||
isOffline: false,
|
||||
isReconnecting: false,
|
||||
reconnectAttempt: 0,
|
||||
nextReconnectDelay: INITIAL_RECONNECT_DELAY,
|
||||
lastOnlineTime: null,
|
||||
queuedMessages: [],
|
||||
maxRetryCount: MAX_RETRY_COUNT,
|
||||
maxQueueSize: MAX_QUEUE_SIZE,
|
||||
|
||||
// === State Management ===
|
||||
|
||||
setOffline: (offline: boolean) => {
|
||||
const wasOffline = get().isOffline;
|
||||
set({
|
||||
isOffline: offline,
|
||||
lastOnlineTime: offline ? get().lastOnlineTime : Date.now(),
|
||||
});
|
||||
|
||||
// Start reconnect process when going offline
|
||||
if (offline && !wasOffline) {
|
||||
get().scheduleReconnect();
|
||||
} else if (!offline && wasOffline) {
|
||||
// Back online - try to send queued messages
|
||||
get().cancelReconnect();
|
||||
set({ reconnectAttempt: 0, nextReconnectDelay: INITIAL_RECONNECT_DELAY });
|
||||
get().retryAllMessages();
|
||||
}
|
||||
},
|
||||
|
||||
setReconnecting: (reconnecting: boolean, attempt?: number) => {
|
||||
set({
|
||||
isReconnecting: reconnecting,
|
||||
reconnectAttempt: attempt ?? get().reconnectAttempt,
|
||||
});
|
||||
},
|
||||
|
||||
// === Message Queue Operations ===
|
||||
|
||||
queueMessage: (content: string, agentId?: string, sessionKey?: string) => {
|
||||
const state = get();
|
||||
|
||||
// Check queue size limit
|
||||
if (state.queuedMessages.length >= state.maxQueueSize) {
|
||||
// Remove oldest pending message
|
||||
const filtered = state.queuedMessages.filter((m, i) =>
|
||||
i > 0 || m.status !== 'pending'
|
||||
);
|
||||
set({ queuedMessages: filtered });
|
||||
}
|
||||
|
||||
const id = generateMessageId();
|
||||
const message: QueuedMessage = {
|
||||
id,
|
||||
content,
|
||||
agentId,
|
||||
sessionKey,
|
||||
timestamp: Date.now(),
|
||||
retryCount: 0,
|
||||
status: 'pending',
|
||||
};
|
||||
|
||||
set((s) => ({
|
||||
queuedMessages: [...s.queuedMessages, message],
|
||||
}));
|
||||
|
||||
console.log(`[OfflineStore] Message queued: ${id}`);
|
||||
return id;
|
||||
},
|
||||
|
||||
updateMessageStatus: (id: string, status: QueuedMessage['status'], error?: string) => {
|
||||
set((s) => ({
|
||||
queuedMessages: s.queuedMessages.map((m) =>
|
||||
m.id === id
|
||||
? {
|
||||
...m,
|
||||
status,
|
||||
lastError: error,
|
||||
retryCount: status === 'failed' ? m.retryCount + 1 : m.retryCount,
|
||||
}
|
||||
: m
|
||||
),
|
||||
}));
|
||||
},
|
||||
|
||||
removeMessage: (id: string) => {
|
||||
set((s) => ({
|
||||
queuedMessages: s.queuedMessages.filter((m) => m.id !== id),
|
||||
}));
|
||||
},
|
||||
|
||||
clearQueue: () => {
|
||||
set({ queuedMessages: [] });
|
||||
},
|
||||
|
||||
retryAllMessages: async () => {
|
||||
const state = get();
|
||||
const pending = state.queuedMessages.filter(
|
||||
(m) => m.status === 'pending' || m.status === 'failed'
|
||||
);
|
||||
|
||||
if (pending.length === 0) return;
|
||||
|
||||
// Check if connected
|
||||
if (getConnectionState() !== 'connected') {
|
||||
console.log('[OfflineStore] Not connected, cannot retry messages');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[OfflineStore] Retrying ${pending.length} queued messages`);
|
||||
|
||||
for (const msg of pending) {
|
||||
if (msg.retryCount >= state.maxRetryCount) {
|
||||
console.log(`[OfflineStore] Message ${msg.id} exceeded max retries`);
|
||||
get().updateMessageStatus(msg.id, 'failed', 'Max retry count exceeded');
|
||||
continue;
|
||||
}
|
||||
|
||||
get().updateMessageStatus(msg.id, 'sending');
|
||||
|
||||
try {
|
||||
// Import gateway client dynamically to avoid circular dependency
|
||||
const { getGatewayClient } = await import('../lib/gateway-client');
|
||||
const client = getGatewayClient();
|
||||
|
||||
await client.chat(msg.content, {
|
||||
sessionKey: msg.sessionKey,
|
||||
agentId: msg.agentId,
|
||||
});
|
||||
|
||||
get().updateMessageStatus(msg.id, 'sent');
|
||||
// Remove sent message after a short delay
|
||||
setTimeout(() => get().removeMessage(msg.id), 1000);
|
||||
console.log(`[OfflineStore] Message ${msg.id} sent successfully`);
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Send failed';
|
||||
get().updateMessageStatus(msg.id, 'failed', errorMessage);
|
||||
console.warn(`[OfflineStore] Message ${msg.id} failed:`, errorMessage);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// === Reconnection ===
|
||||
|
||||
scheduleReconnect: () => {
|
||||
const state = get();
|
||||
|
||||
// Don't schedule if already online
|
||||
if (!state.isOffline) return;
|
||||
|
||||
// Cancel any existing timer
|
||||
get().cancelReconnect();
|
||||
|
||||
const attempt = state.reconnectAttempt + 1;
|
||||
const delay = state.nextReconnectDelay;
|
||||
|
||||
console.log(`[OfflineStore] Scheduling reconnect attempt ${attempt} in ${delay}ms`);
|
||||
|
||||
set({
|
||||
isReconnecting: true,
|
||||
reconnectAttempt: attempt,
|
||||
nextReconnectDelay: calculateNextDelay(delay),
|
||||
});
|
||||
|
||||
reconnectTimer = setTimeout(() => {
|
||||
get().attemptReconnect();
|
||||
}, delay);
|
||||
},
|
||||
|
||||
cancelReconnect: () => {
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer);
|
||||
reconnectTimer = null;
|
||||
}
|
||||
set({ isReconnecting: false });
|
||||
},
|
||||
|
||||
attemptReconnect: async () => {
|
||||
console.log('[OfflineStore] Attempting to reconnect...');
|
||||
|
||||
try {
|
||||
// Try to connect via connection store
|
||||
await useConnectionStore.getState().connect();
|
||||
|
||||
// Check if now connected
|
||||
if (getConnectionState() === 'connected') {
|
||||
console.log('[OfflineStore] Reconnection successful');
|
||||
get().setOffline(false);
|
||||
return true;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[OfflineStore] Reconnection failed:', err);
|
||||
}
|
||||
|
||||
// Still offline, schedule next attempt
|
||||
get().setReconnecting(false);
|
||||
get().scheduleReconnect();
|
||||
return false;
|
||||
},
|
||||
|
||||
// === Getters ===
|
||||
|
||||
getPendingMessages: () => {
|
||||
return get().queuedMessages.filter(
|
||||
(m) => m.status === 'pending' || m.status === 'failed'
|
||||
);
|
||||
},
|
||||
|
||||
hasPendingMessages: () => {
|
||||
return get().queuedMessages.some(
|
||||
(m) => m.status === 'pending' || m.status === 'failed'
|
||||
);
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'zclaw-offline-storage',
|
||||
partialize: (state) => ({
|
||||
queuedMessages: state.queuedMessages.filter(
|
||||
(m) => m.status === 'pending' || m.status === 'failed'
|
||||
),
|
||||
lastOnlineTime: state.lastOnlineTime,
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// === Connection State Monitor ===
|
||||
|
||||
/**
|
||||
* Start monitoring connection state and update offline store accordingly.
|
||||
* Should be called once during app initialization.
|
||||
*/
|
||||
export function startOfflineMonitor(): () => void {
|
||||
const checkConnection = () => {
|
||||
const connectionState = getConnectionState();
|
||||
const isOffline = connectionState !== 'connected';
|
||||
|
||||
if (isOffline !== useOfflineStore.getState().isOffline) {
|
||||
useOfflineStore.getState().setOffline(isOffline);
|
||||
}
|
||||
};
|
||||
|
||||
// Initial check
|
||||
checkConnection();
|
||||
|
||||
// Subscribe to connection state changes
|
||||
const unsubscribe = useConnectionStore.subscribe((state, prevState) => {
|
||||
if (state.connectionState !== prevState.connectionState) {
|
||||
const isOffline = state.connectionState !== 'connected';
|
||||
useOfflineStore.getState().setOffline(isOffline);
|
||||
}
|
||||
});
|
||||
|
||||
// Periodic health check (every 30 seconds)
|
||||
healthCheckInterval = setInterval(checkConnection, 30000);
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
if (healthCheckInterval) {
|
||||
clearInterval(healthCheckInterval);
|
||||
healthCheckInterval = null;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// === Exported Accessors ===
|
||||
|
||||
export const isOffline = () => useOfflineStore.getState().isOffline;
|
||||
export const getQueuedMessages = () => useOfflineStore.getState().queuedMessages;
|
||||
export const hasPendingMessages = () => useOfflineStore.getState().hasPendingMessages();
|
||||
@@ -4,7 +4,7 @@
|
||||
* 基于实际 API 端点: http://127.0.0.1:50051
|
||||
*/
|
||||
|
||||
import { Page } from '@playwright/test';
|
||||
import { Page, WebSocketRoute } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Mock 响应数据模板 - 基于实际 API 响应格式
|
||||
@@ -440,7 +440,7 @@ export async function setupMockGateway(
|
||||
});
|
||||
|
||||
// Mock Hand 运行状态 - GET /api/hands/{name}/runs/{runId}
|
||||
await page.route('**/api/hands/*/runs/*', async (route) => {
|
||||
await page.route('**/api/hands/*/runs/**', async (route) => {
|
||||
if (simulateDelay) await delay(delayMs);
|
||||
const method = route.request().method();
|
||||
const url = route.request().url();
|
||||
@@ -468,6 +468,13 @@ export async function setupMockGateway(
|
||||
completedAt: new Date().toISOString(),
|
||||
}),
|
||||
});
|
||||
} else {
|
||||
// Fallback for any other requests
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ status: 'ok' }),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -481,6 +488,26 @@ export async function setupMockGateway(
|
||||
});
|
||||
});
|
||||
|
||||
// Mock Hand 审批 - POST /api/hands/{name}/runs/{runId}/approve
|
||||
await page.route('**/api/hands/*/runs/*/approve', async (route) => {
|
||||
if (simulateDelay) await delay(delayMs);
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ status: 'approved' }),
|
||||
});
|
||||
});
|
||||
|
||||
// Mock Hand 取消 - POST /api/hands/{name}/runs/{runId}/cancel
|
||||
await page.route('**/api/hands/*/runs/*/cancel', async (route) => {
|
||||
if (simulateDelay) await delay(delayMs);
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ status: 'cancelled' }),
|
||||
});
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// Workflow 端点
|
||||
// ========================================
|
||||
@@ -777,6 +804,153 @@ export async function mockTimeout(page: Page, path: string): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* WebSocket Mock 配置
|
||||
*/
|
||||
export interface MockWebSocketConfig {
|
||||
/** 模拟响应内容 */
|
||||
responseContent?: string;
|
||||
/** 是否模拟流式响应 */
|
||||
streaming?: boolean;
|
||||
/** 流式响应的块延迟 (ms) */
|
||||
chunkDelay?: number;
|
||||
/** 是否模拟错误 */
|
||||
simulateError?: boolean;
|
||||
/** 错误消息 */
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 存储 WebSocket Mock 配置
|
||||
*/
|
||||
let wsConfig: MockWebSocketConfig = {
|
||||
responseContent: 'This is a mock streaming response from the WebSocket server.',
|
||||
streaming: true,
|
||||
chunkDelay: 50,
|
||||
};
|
||||
|
||||
/**
|
||||
* 设置 WebSocket Mock 配置
|
||||
*/
|
||||
export function setWebSocketConfig(config: Partial<MockWebSocketConfig>): void {
|
||||
wsConfig = { ...wsConfig, ...config };
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock Agent WebSocket 流式响应
|
||||
* 使用 Playwright 的 routeWebSocket API 拦截 WebSocket 连接
|
||||
*/
|
||||
export async function mockAgentWebSocket(
|
||||
page: Page,
|
||||
config: Partial<MockWebSocketConfig> = {}
|
||||
): Promise<void> {
|
||||
const finalConfig = { ...wsConfig, ...config };
|
||||
|
||||
await page.routeWebSocket('**/api/agents/*/ws', async (ws: WebSocketRoute) => {
|
||||
// Handle incoming messages from the page
|
||||
ws.onMessage(async (message) => {
|
||||
try {
|
||||
const data = JSON.parse(message);
|
||||
|
||||
// Handle chat message
|
||||
if (data.type === 'message' || data.content) {
|
||||
// Send connected event first
|
||||
ws.send(JSON.stringify({
|
||||
type: 'connected',
|
||||
agent_id: 'default-agent',
|
||||
}));
|
||||
|
||||
// Simulate error if configured
|
||||
if (finalConfig.simulateError) {
|
||||
ws.send(JSON.stringify({
|
||||
type: 'error',
|
||||
message: finalConfig.errorMessage || 'Mock WebSocket error',
|
||||
}));
|
||||
ws.close({ code: 1011, reason: 'Error' });
|
||||
return;
|
||||
}
|
||||
|
||||
const responseText = finalConfig.responseContent || 'Mock response';
|
||||
|
||||
if (finalConfig.streaming) {
|
||||
// Send typing indicator
|
||||
ws.send(JSON.stringify({
|
||||
type: 'typing',
|
||||
state: 'start',
|
||||
}));
|
||||
|
||||
// Stream response in chunks
|
||||
const words = responseText.split(' ');
|
||||
let current = '';
|
||||
|
||||
for (let i = 0; i < words.length; i++) {
|
||||
current += (current ? ' ' : '') + words[i];
|
||||
|
||||
// Send text delta every few words
|
||||
if (current.length >= 10 || i === words.length - 1) {
|
||||
await new Promise(resolve => setTimeout(resolve, finalConfig.chunkDelay || 50));
|
||||
ws.send(JSON.stringify({
|
||||
type: 'text_delta',
|
||||
content: current,
|
||||
}));
|
||||
current = '';
|
||||
}
|
||||
}
|
||||
|
||||
// Send typing stop
|
||||
ws.send(JSON.stringify({
|
||||
type: 'typing',
|
||||
state: 'stop',
|
||||
}));
|
||||
|
||||
// Send phase done
|
||||
ws.send(JSON.stringify({
|
||||
type: 'phase',
|
||||
phase: 'done',
|
||||
}));
|
||||
} else {
|
||||
// Non-streaming response
|
||||
ws.send(JSON.stringify({
|
||||
type: 'response',
|
||||
content: responseText,
|
||||
input_tokens: 100,
|
||||
output_tokens: responseText.split(' ').length,
|
||||
}));
|
||||
}
|
||||
|
||||
// Close connection after response
|
||||
ws.close({ code: 1000, reason: 'Stream complete' });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('WebSocket mock error:', err);
|
||||
ws.send(JSON.stringify({
|
||||
type: 'error',
|
||||
message: 'Failed to parse message',
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
// Handle connection close from page
|
||||
ws.onClose(() => {
|
||||
// Clean up
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置完整的 Gateway Mock (包括 WebSocket)
|
||||
*/
|
||||
export async function setupMockGatewayWithWebSocket(
|
||||
page: Page,
|
||||
config: MockGatewayConfig & { wsConfig?: Partial<MockWebSocketConfig> } = {}
|
||||
): Promise<void> {
|
||||
// Setup HTTP mocks
|
||||
await setupMockGateway(page, config);
|
||||
|
||||
// Setup WebSocket mock
|
||||
await mockAgentWebSocket(page, config.wsConfig || {});
|
||||
}
|
||||
|
||||
// 辅助函数
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
@@ -68,8 +68,23 @@ export const storeInspectors = {
|
||||
|
||||
/**
|
||||
* 获取持久化的 Chat Store 状态
|
||||
* 优先从运行时获取,如果不可用则从 localStorage 获取
|
||||
*/
|
||||
async getChatState<T = unknown>(page: Page): Promise<T | null> {
|
||||
// First try to get runtime state (more reliable for E2E tests)
|
||||
const runtimeState = await page.evaluate(() => {
|
||||
const stores = (window as any).__ZCLAW_STORES__;
|
||||
if (stores && stores.chat) {
|
||||
return stores.chat.getState() as T;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
if (runtimeState) {
|
||||
return runtimeState;
|
||||
}
|
||||
|
||||
// Fallback to localStorage
|
||||
return page.evaluate((key) => {
|
||||
const stored = localStorage.getItem(key);
|
||||
if (!stored) return null;
|
||||
|
||||
@@ -15,6 +15,7 @@ import { setupMockGateway, mockAgentMessageResponse, mockResponses, mockErrorRes
|
||||
import { storeInspectors, STORE_NAMES } from '../fixtures/store-inspectors';
|
||||
import { userActions, waitForAppReady, skipOnboarding, navigateToTab } from '../utils/user-actions';
|
||||
import { networkHelpers } from '../utils/network-helpers';
|
||||
import { setupMockGatewayWithWebSocket, setWebSocketConfig } from '../fixtures/mock-gateway';
|
||||
|
||||
// Test configuration
|
||||
test.setTimeout(120000);
|
||||
@@ -168,16 +169,15 @@ test.describe('Chat Message Tests', () => {
|
||||
test.describe.configure({ mode: 'parallel' }); // Parallel for isolation
|
||||
|
||||
test('CHAT-MSG-01: Send message and receive response', async ({ page }) => {
|
||||
// Setup mock gateway
|
||||
await setupMockGateway(page);
|
||||
// Setup mock gateway with WebSocket support
|
||||
const mockResponse = 'This is a mock AI response for testing purposes.';
|
||||
await setupMockGatewayWithWebSocket(page, {
|
||||
wsConfig: { responseContent: mockResponse, streaming: true }
|
||||
});
|
||||
await skipOnboarding(page);
|
||||
await page.goto(BASE_URL);
|
||||
await waitForAppReady(page);
|
||||
|
||||
// Mock agent message response
|
||||
const mockResponse = 'This is a mock AI response for testing purposes.';
|
||||
await mockAgentMessageResponse(page, mockResponse);
|
||||
|
||||
// Find chat input
|
||||
const chatInput = page.locator('textarea').first();
|
||||
await expect(chatInput).toBeVisible({ timeout: 10000 });
|
||||
@@ -209,12 +209,13 @@ test.describe('Chat Message Tests', () => {
|
||||
});
|
||||
|
||||
test('CHAT-MSG-02: Message updates store state', async ({ page }) => {
|
||||
// Setup fresh page
|
||||
await setupMockGateway(page);
|
||||
// Setup fresh page with WebSocket support
|
||||
await setupMockGatewayWithWebSocket(page, {
|
||||
wsConfig: { responseContent: 'Store state test response', streaming: true }
|
||||
});
|
||||
await skipOnboarding(page);
|
||||
await page.goto(BASE_URL);
|
||||
await waitForAppReady(page);
|
||||
await mockAgentMessageResponse(page, 'Store state test response');
|
||||
|
||||
// Clear any existing messages first
|
||||
await storeInspectors.clearStore(page, STORE_NAMES.CHAT);
|
||||
@@ -243,12 +244,13 @@ test.describe('Chat Message Tests', () => {
|
||||
});
|
||||
|
||||
test('CHAT-MSG-03: Streaming response indicator', async ({ page }) => {
|
||||
// Setup fresh page
|
||||
await setupMockGateway(page);
|
||||
// Setup fresh page with WebSocket support
|
||||
await setupMockGatewayWithWebSocket(page, {
|
||||
wsConfig: { responseContent: 'Streaming test response with longer content', streaming: true, chunkDelay: 100 }
|
||||
});
|
||||
await skipOnboarding(page);
|
||||
await page.goto(BASE_URL);
|
||||
await waitForAppReady(page);
|
||||
await mockAgentMessageResponse(page, 'Streaming test response with longer content');
|
||||
|
||||
const chatInput = page.locator('textarea').first();
|
||||
await chatInput.fill('Write a short poem');
|
||||
@@ -276,8 +278,10 @@ test.describe('Chat Message Tests', () => {
|
||||
});
|
||||
|
||||
test('CHAT-MSG-04: Error handling for failed message', async ({ page }) => {
|
||||
// Setup fresh page with error mock
|
||||
await mockErrorResponse(page, 'health', 500, 'Internal Server Error');
|
||||
// Setup fresh page with error mock - WebSocket will simulate error
|
||||
await setupMockGatewayWithWebSocket(page, {
|
||||
wsConfig: { simulateError: true, errorMessage: 'WebSocket connection failed' }
|
||||
});
|
||||
await skipOnboarding(page);
|
||||
await page.goto(BASE_URL);
|
||||
await waitForAppReady(page);
|
||||
@@ -294,12 +298,13 @@ test.describe('Chat Message Tests', () => {
|
||||
});
|
||||
|
||||
test('CHAT-MSG-05: Multiple messages in sequence', async ({ page }) => {
|
||||
// Setup fresh page
|
||||
await setupMockGateway(page);
|
||||
// Setup fresh page with WebSocket support
|
||||
await setupMockGatewayWithWebSocket(page, {
|
||||
wsConfig: { responseContent: 'Response to sequential message', streaming: true }
|
||||
});
|
||||
await skipOnboarding(page);
|
||||
await page.goto(BASE_URL);
|
||||
await waitForAppReady(page);
|
||||
await mockAgentMessageResponse(page, 'Response to sequential message');
|
||||
|
||||
// Clear existing messages
|
||||
await storeInspectors.clearStore(page, STORE_NAMES.CHAT);
|
||||
|
||||
500
desktop/tests/lib/security.test.ts
Normal file
500
desktop/tests/lib/security.test.ts
Normal file
@@ -0,0 +1,500 @@
|
||||
/**
|
||||
* Security Module Tests
|
||||
*
|
||||
* Unit tests for crypto utilities, security utils, and audit logging.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
|
||||
// ============================================================================
|
||||
// Crypto Utils Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('Crypto Utils', () => {
|
||||
// Import dynamically to handle Web Crypto API
|
||||
let cryptoUtils: typeof import('../../src/lib/crypto-utils');
|
||||
|
||||
beforeEach(async () => {
|
||||
cryptoUtils = await import('../../src/lib/crypto-utils');
|
||||
});
|
||||
|
||||
describe('arrayToBase64 / base64ToArray', () => {
|
||||
it('should convert Uint8Array to base64 and back', () => {
|
||||
const original = new Uint8Array([72, 101, 108, 108, 111]);
|
||||
const base64 = cryptoUtils.arrayToBase64(original);
|
||||
const decoded = cryptoUtils.base64ToArray(base64);
|
||||
|
||||
expect(decoded).toEqual(original);
|
||||
});
|
||||
|
||||
it('should handle empty array', () => {
|
||||
const empty = new Uint8Array([]);
|
||||
expect(cryptoUtils.arrayToBase64(empty)).toBe('');
|
||||
expect(cryptoUtils.base64ToArray('')).toEqual(empty);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deriveKey', () => {
|
||||
it('should derive a CryptoKey from a master key', async () => {
|
||||
const key = await cryptoUtils.deriveKey('test-master-key');
|
||||
|
||||
expect(key).toBeDefined();
|
||||
expect(key.type).toBe('secret');
|
||||
});
|
||||
|
||||
it('should derive the same key for the same input', async () => {
|
||||
const key1 = await cryptoUtils.deriveKey('test-master-key');
|
||||
const key2 = await cryptoUtils.deriveKey('test-master-key');
|
||||
|
||||
// Both should be valid CryptoKey objects
|
||||
expect(key1).toBeDefined();
|
||||
expect(key2).toBeDefined();
|
||||
});
|
||||
|
||||
it('should use custom salt', async () => {
|
||||
const customSalt = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]);
|
||||
const key = await cryptoUtils.deriveKey('test-master-key', customSalt);
|
||||
|
||||
expect(key).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('encrypt / decrypt', () => {
|
||||
it('should encrypt and decrypt a string', async () => {
|
||||
const key = await cryptoUtils.deriveKey('test-master-key');
|
||||
const plaintext = 'Hello, World!';
|
||||
|
||||
const encrypted = await cryptoUtils.encrypt(plaintext, key);
|
||||
expect(encrypted.iv).toBeDefined();
|
||||
expect(encrypted.data).toBeDefined();
|
||||
expect(encrypted.version).toBe(1);
|
||||
|
||||
const decrypted = await cryptoUtils.decrypt(encrypted, key);
|
||||
expect(decrypted).toBe(plaintext);
|
||||
});
|
||||
|
||||
it('should produce different IVs for same plaintext', async () => {
|
||||
const key = await cryptoUtils.deriveKey('test-master-key');
|
||||
const plaintext = 'Same text';
|
||||
|
||||
const encrypted1 = await cryptoUtils.encrypt(plaintext, key);
|
||||
const encrypted2 = await cryptoUtils.encrypt(plaintext, key);
|
||||
|
||||
expect(encrypted1.iv).not.toBe(encrypted2.iv);
|
||||
});
|
||||
|
||||
it('should handle empty string', async () => {
|
||||
const key = await cryptoUtils.deriveKey('test-master-key');
|
||||
const plaintext = '';
|
||||
|
||||
const encrypted = await cryptoUtils.encrypt(plaintext, key);
|
||||
const decrypted = await cryptoUtils.decrypt(encrypted, key);
|
||||
|
||||
expect(decrypted).toBe('');
|
||||
});
|
||||
|
||||
it('should handle unicode characters', async () => {
|
||||
const key = await cryptoUtils.deriveKey('test-master-key');
|
||||
const plaintext = '中文测试 日本語 한국어 🎉';
|
||||
|
||||
const encrypted = await cryptoUtils.encrypt(plaintext, key);
|
||||
const decrypted = await cryptoUtils.decrypt(encrypted, key);
|
||||
|
||||
expect(decrypted).toBe(plaintext);
|
||||
});
|
||||
});
|
||||
|
||||
describe('encryptObject / decryptObject', () => {
|
||||
it('should encrypt and decrypt an object', async () => {
|
||||
const key = await cryptoUtils.deriveKey('test-master-key');
|
||||
const obj = { name: 'test', count: 42, nested: { a: 1 } };
|
||||
|
||||
const encrypted = await cryptoUtils.encryptObject(obj, key);
|
||||
const decrypted = await cryptoUtils.decryptObject<typeof obj>(encrypted, key);
|
||||
|
||||
expect(decrypted).toEqual(obj);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateMasterKey', () => {
|
||||
it('should generate a base64 string', () => {
|
||||
const key = cryptoUtils.generateMasterKey();
|
||||
|
||||
expect(typeof key).toBe('string');
|
||||
expect(key.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should generate unique keys', () => {
|
||||
const key1 = cryptoUtils.generateMasterKey();
|
||||
const key2 = cryptoUtils.generateMasterKey();
|
||||
|
||||
expect(key1).not.toBe(key2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hashSha256', () => {
|
||||
it('should produce consistent hash', async () => {
|
||||
const input = 'test-input';
|
||||
const hash1 = await cryptoUtils.hashSha256(input);
|
||||
const hash2 = await cryptoUtils.hashSha256(input);
|
||||
|
||||
expect(hash1).toBe(hash2);
|
||||
expect(hash1.length).toBe(64); // SHA-256 produces 64 hex chars
|
||||
});
|
||||
});
|
||||
|
||||
describe('constantTimeEqual', () => {
|
||||
it('should return true for equal arrays', () => {
|
||||
const a = new Uint8Array([1, 2, 3, 4]);
|
||||
const b = new Uint8Array([1, 2, 3, 4]);
|
||||
|
||||
expect(cryptoUtils.constantTimeEqual(a, b)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for different arrays', () => {
|
||||
const a = new Uint8Array([1, 2, 3, 4]);
|
||||
const b = new Uint8Array([1, 2, 3, 5]);
|
||||
|
||||
expect(cryptoUtils.constantTimeEqual(a, b)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for different length arrays', () => {
|
||||
const a = new Uint8Array([1, 2, 3]);
|
||||
const b = new Uint8Array([1, 2, 3, 4]);
|
||||
|
||||
expect(cryptoUtils.constantTimeEqual(a, b)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isValidEncryptedData', () => {
|
||||
it('should validate correct structure', () => {
|
||||
const valid = { iv: 'abc', data: 'xyz' };
|
||||
expect(cryptoUtils.isValidEncryptedData(valid)).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject invalid structures', () => {
|
||||
expect(cryptoUtils.isValidEncryptedData(null)).toBe(false);
|
||||
expect(cryptoUtils.isValidEncryptedData({})).toBe(false);
|
||||
expect(cryptoUtils.isValidEncryptedData({ iv: '' })).toBe(false);
|
||||
expect(cryptoUtils.isValidEncryptedData({ data: '' })).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Security Utils Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('Security Utils', () => {
|
||||
let securityUtils: typeof import('../security-utils');
|
||||
|
||||
beforeEach(async () => {
|
||||
securityUtils = await import('../security-utils');
|
||||
});
|
||||
|
||||
describe('escapeHtml', () => {
|
||||
it('should escape HTML special characters', () => {
|
||||
const input = '<script>alert("xss")</script>';
|
||||
const escaped = securityUtils.escapeHtml(input);
|
||||
|
||||
expect(escaped).toBe('<script>alert("xss")</script>');
|
||||
});
|
||||
|
||||
it('should handle plain text', () => {
|
||||
const input = 'Hello, World!';
|
||||
expect(securityUtils.escapeHtml(input)).toBe(input);
|
||||
});
|
||||
|
||||
it('should handle ampersand', () => {
|
||||
expect(securityUtils.escapeHtml('a & b')).toBe('a & b');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sanitizeHtml', () => {
|
||||
it('should remove script tags', () => {
|
||||
const html = '<p>Hello</p><script>alert("xss")</script>';
|
||||
const sanitized = securityUtils.sanitizeHtml(html);
|
||||
|
||||
expect(sanitized).not.toContain('<script>');
|
||||
expect(sanitized).toContain('<p>');
|
||||
});
|
||||
|
||||
it('should remove event handlers', () => {
|
||||
const html = '<div onclick="alert(1)">Click me</div>';
|
||||
const sanitized = securityUtils.sanitizeHtml(html);
|
||||
|
||||
expect(sanitized).not.toContain('onclick');
|
||||
});
|
||||
|
||||
it('should remove javascript: URLs', () => {
|
||||
const html = '<a href="javascript:alert(1)">Link</a>';
|
||||
const sanitized = securityUtils.sanitizeHtml(html);
|
||||
|
||||
expect(sanitized).not.toContain('javascript:');
|
||||
});
|
||||
|
||||
it('should preserve allowed tags', () => {
|
||||
const html = '<p><strong>Bold</strong> and <em>italic</em></p>';
|
||||
const sanitized = securityUtils.sanitizeHtml(html);
|
||||
|
||||
expect(sanitized).toContain('<p>');
|
||||
expect(sanitized).toContain('<strong>');
|
||||
expect(sanitized).toContain('<em>');
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateUrl', () => {
|
||||
it('should validate http URLs', () => {
|
||||
const url = 'https://example.com/path';
|
||||
expect(securityUtils.validateUrl(url)).toBe(url);
|
||||
});
|
||||
|
||||
it('should reject javascript: URLs', () => {
|
||||
expect(securityUtils.validateUrl('javascript:alert(1)')).toBeNull();
|
||||
});
|
||||
|
||||
it('should reject data: URLs by default', () => {
|
||||
expect(securityUtils.validateUrl('data:text/html,<script>alert(1)</script>')).toBeNull();
|
||||
});
|
||||
|
||||
it('should reject localhost when not allowed', () => {
|
||||
expect(
|
||||
securityUtils.validateUrl('http://localhost:3000', { allowLocalhost: false })
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('should allow localhost when allowed', () => {
|
||||
const url = 'http://localhost:3000';
|
||||
expect(
|
||||
securityUtils.validateUrl(url, { allowLocalhost: true })
|
||||
).toBe(url);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validatePath', () => {
|
||||
it('should reject path traversal', () => {
|
||||
expect(securityUtils.validatePath('../../../etc/passwd')).toBeNull();
|
||||
});
|
||||
|
||||
it('should reject null bytes', () => {
|
||||
expect(securityUtils.validatePath('file\0.txt')).toBeNull();
|
||||
});
|
||||
|
||||
it('should reject absolute paths when not allowed', () => {
|
||||
expect(
|
||||
securityUtils.validatePath('/etc/passwd', { allowAbsolute: false })
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('should validate relative paths', () => {
|
||||
const result = securityUtils.validatePath('folder/file.txt');
|
||||
expect(result).toBe('folder/file.txt');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isValidEmail', () => {
|
||||
it('should validate correct emails', () => {
|
||||
expect(securityUtils.isValidEmail('test@example.com')).toBe(true);
|
||||
expect(securityUtils.isValidEmail('user.name@subdomain.example.com')).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject invalid emails', () => {
|
||||
expect(securityUtils.isValidEmail('not-an-email')).toBe(false);
|
||||
expect(securityUtils.isValidEmail('@example.com')).toBe(false);
|
||||
expect(securityUtils.isValidEmail('test@')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validatePasswordStrength', () => {
|
||||
it('should accept strong passwords', () => {
|
||||
const result = securityUtils.validatePasswordStrength('Str0ng!Pass');
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.score).toBeGreaterThan(50);
|
||||
});
|
||||
|
||||
it('should reject short passwords', () => {
|
||||
const result = securityUtils.validatePasswordStrength('short');
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.issues).toContain('Password must be at least 8 characters');
|
||||
});
|
||||
|
||||
it('should detect weak patterns', () => {
|
||||
const result = securityUtils.validatePasswordStrength('password123');
|
||||
expect(result.issues).toContain('Password contains a common pattern');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sanitizeFilename', () => {
|
||||
it('should remove path separators', () => {
|
||||
expect(securityUtils.sanitizeFilename('../test.txt')).toBe('.._test.txt');
|
||||
});
|
||||
|
||||
it('should remove dangerous characters', () => {
|
||||
const sanitized = securityUtils.sanitizeFilename('file<>:"|?*.txt');
|
||||
expect(sanitized).not.toMatch(/[<>:"|?*]/);
|
||||
});
|
||||
|
||||
it('should handle normal filenames', () => {
|
||||
expect(securityUtils.sanitizeFilename('document.pdf')).toBe('document.pdf');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sanitizeJson', () => {
|
||||
it('should parse valid JSON', () => {
|
||||
const result = securityUtils.sanitizeJson('{"name":"test"}');
|
||||
expect(result).toEqual({ name: 'test' });
|
||||
});
|
||||
|
||||
it('should remove prototype pollution keys', () => {
|
||||
const result = securityUtils.sanitizeJson('{"__proto__":{"admin":true},"name":"test"}');
|
||||
expect(result).not.toHaveProperty('__proto__');
|
||||
});
|
||||
|
||||
it('should return null for invalid JSON', () => {
|
||||
expect(securityUtils.sanitizeJson('not json')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isRateLimited', () => {
|
||||
beforeEach(() => {
|
||||
// Clear rate limit store
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('should allow first request', () => {
|
||||
expect(securityUtils.isRateLimited('test-key', 5, 60000)).toBe(false);
|
||||
});
|
||||
|
||||
it('should block after limit reached', () => {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
securityUtils.isRateLimited('test-key', 5, 60000);
|
||||
}
|
||||
expect(securityUtils.isRateLimited('test-key', 5, 60000)).toBe(true);
|
||||
});
|
||||
|
||||
it('should reset after window expires', () => {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
securityUtils.isRateLimited('test-key', 5, 60000);
|
||||
}
|
||||
expect(securityUtils.isRateLimited('test-key', 5, 60000)).toBe(true);
|
||||
|
||||
// Advance time past window
|
||||
vi.advanceTimersByTime(61000);
|
||||
expect(securityUtils.isRateLimited('test-key', 5, 60000)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateSecureToken', () => {
|
||||
it('should generate hex string of correct length', () => {
|
||||
const token = securityUtils.generateSecureToken(16);
|
||||
expect(token.length).toBe(32); // 16 bytes = 32 hex chars
|
||||
});
|
||||
|
||||
it('should generate unique tokens', () => {
|
||||
const token1 = securityUtils.generateSecureToken();
|
||||
const token2 = securityUtils.generateSecureToken();
|
||||
expect(token1).not.toBe(token2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateSecureId', () => {
|
||||
it('should generate ID with prefix', () => {
|
||||
const id = securityUtils.generateSecureId('user');
|
||||
expect(id.startsWith('user_')).toBe(true);
|
||||
});
|
||||
|
||||
it('should generate ID without prefix', () => {
|
||||
const id = securityUtils.generateSecureId();
|
||||
expect(id).toMatch(/^\w+_\w+$/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Security Audit Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('Security Audit', () => {
|
||||
let securityAudit: typeof import('../security-audit');
|
||||
|
||||
beforeEach(async () => {
|
||||
securityAudit = await import('../security-audit');
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
securityAudit.clearSecurityAuditLog();
|
||||
});
|
||||
|
||||
describe('logSecurityEvent', () => {
|
||||
it('should log security events', () => {
|
||||
securityAudit.logSecurityEvent('auth_login', 'User logged in', { userId: '123' });
|
||||
|
||||
const events = securityAudit.getSecurityEvents();
|
||||
expect(events.length).toBe(1);
|
||||
expect(events[0].type).toBe('auth_login');
|
||||
expect(events[0].message).toBe('User logged in');
|
||||
});
|
||||
|
||||
it('should determine severity automatically', () => {
|
||||
securityAudit.logSecurityEvent('security_violation', 'Test violation');
|
||||
securityAudit.logSecurityEvent('auth_login', 'Test login');
|
||||
|
||||
const events = securityAudit.getSecurityEvents();
|
||||
expect(events[0].severity).toBe('critical');
|
||||
expect(events[1].severity).toBe('info');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSecurityEventsByType', () => {
|
||||
it('should filter events by type', () => {
|
||||
securityAudit.logSecurityEvent('auth_login', 'Login 1');
|
||||
securityAudit.logSecurityEvent('auth_logout', 'Logout');
|
||||
securityAudit.logSecurityEvent('auth_login', 'Login 2');
|
||||
|
||||
const loginEvents = securityAudit.getSecurityEventsByType('auth_login');
|
||||
expect(loginEvents.length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSecurityEventsBySeverity', () => {
|
||||
it('should filter events by severity', () => {
|
||||
securityAudit.logSecurityEvent('auth_login', 'Login', {}, 'info');
|
||||
securityAudit.logSecurityEvent('auth_failed', 'Failed', {}, 'warning');
|
||||
|
||||
const infoEvents = securityAudit.getSecurityEventsBySeverity('info');
|
||||
expect(infoEvents.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateSecurityAuditReport', () => {
|
||||
it('should generate a report', () => {
|
||||
securityAudit.logSecurityEvent('auth_login', 'Login');
|
||||
securityAudit.logSecurityEvent('auth_failed', 'Failed');
|
||||
securityAudit.logSecurityEvent('security_violation', 'Violation');
|
||||
|
||||
const report = securityAudit.generateSecurityAuditReport();
|
||||
|
||||
expect(report.totalEvents).toBe(3);
|
||||
expect(report.eventsByType.auth_login).toBe(1);
|
||||
expect(report.eventsBySeverity.critical).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setAuditEnabled', () => {
|
||||
it('should disable logging when set to false', () => {
|
||||
securityAudit.setAuditEnabled(false);
|
||||
securityAudit.logSecurityEvent('auth_login', 'Should not log');
|
||||
securityAudit.setAuditEnabled(true);
|
||||
|
||||
const events = securityAudit.getSecurityEvents();
|
||||
// Only the config_changed event should be logged
|
||||
const loginEvents = events.filter(e => e.type === 'auth_login');
|
||||
expect(loginEvents.length).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user