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:
iven
2026-03-22 00:03:22 +08:00
parent ce562e8bfc
commit 185763868a
27 changed files with 5725 additions and 268 deletions

228
.gitea/workflows/ci.yml Normal file
View 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

View 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"

View File

@@ -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`}>

View File

@@ -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>

View 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;

View File

@@ -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}
/>
);
}

View File

@@ -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,

View File

@@ -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 };

View 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>
);
}

View File

@@ -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>
);
}

View File

@@ -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';

View 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;
}

View File

@@ -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
);
}

View 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;
}
}

View File

@@ -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.

View File

@@ -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;
}

View 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;
}

View 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,
};
}

View 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> = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#x27;',
'/': '&#x2F;',
'`': '&#x60;',
'=': '&#x3D;',
};
/**
* 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}`;
}

View File

@@ -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>,
);

View File

@@ -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;
}

View File

@@ -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
});
}

View 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();

View File

@@ -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));

View File

@@ -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;

View File

@@ -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);

View 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('&lt;script&gt;alert(&quot;xss&quot;)&lt;&#x2F;script&gt;');
});
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 &amp; 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);
});
});
});