feat(ui): Phase 8 UI/UX optimization and system documentation update

## Sidebar Enhancement
- Change tabs to icon + small label layout for better space utilization
- Add Teams tab with team collaboration entry point

## Settings Page Improvements
- Connect theme toggle to gatewayStore.saveQuickConfig for persistence
- Remove OpenFang backend download section, simplify UI
- Add time range filter to UsageStats (7d/30d/all)
- Add stat cards with icons (sessions, messages, input/output tokens)
- Add token usage overview bar chart
- Add 8 ZCLAW system skill definitions with categories

## Bug Fixes
- Fix ChannelList duplicate content with deduplication logic
- Integrate CreateTriggerModal in TriggersPanel
- Add independent SecurityStatusPanel with 12 default enabled layers
- Change workflow view to use SchedulerPanel as unified entry

## New Components
- CreateTriggerModal: Event trigger creation modal
- HandApprovalModal: Hand approval workflow dialog
- HandParamsForm: Enhanced Hand parameter form
- SecurityLayersPanel: 16-layer security status display

## Architecture
- Add TOML config parsing support (toml-utils.ts, config-parser.ts)
- Add request timeout and retry mechanism (request-helper.ts)
- Add secure token storage (secure-storage.ts, secure_storage.rs)

## Tests
- Add unit tests for config-parser, toml-utils, request-helper
- Add team-client and teamStore tests

## Documentation
- Update SYSTEM_ANALYSIS.md with Phase 8 completion
- UI completion: 100% (30/30 components)
- API coverage: 93% (63/68 endpoints)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
iven
2026-03-15 14:12:11 +08:00
parent bf79c06d4a
commit 3e81bd3e50
30 changed files with 8875 additions and 284 deletions

View File

@@ -0,0 +1,377 @@
/**
* OpenFang Configuration Parser
*
* Provides configuration parsing, validation, and serialization for OpenFang TOML files.
*
* @module lib/config-parser
*/
import { tomlUtils, TomlParseError } from './toml-utils';
import type {
OpenFangConfig,
ConfigValidationResult,
ConfigValidationError,
ConfigValidationWarning,
ConfigFileMetadata,
ServerConfig,
AgentSectionConfig,
LLMConfig,
} from '../types/config';
/**
* Error class for configuration parsing errors
*/
export class ConfigParseError extends Error {
constructor(
message: string,
public readonly cause?: unknown
) {
super(message);
this.name = 'ConfigParseError';
}
}
/**
* Error class for configuration validation errors (thrown when validation fails)
*/
export class ConfigValidationFailedError extends Error {
constructor(
message: string,
public readonly errors: ConfigValidationError[]
) {
super(message);
this.name = 'ConfigValidationFailedError';
}
}
/**
* Required configuration fields with their paths
*/
const REQUIRED_FIELDS: Array<{ path: string; description: string }> = [
{ path: 'server', description: 'Server configuration' },
{ path: 'server.host', description: 'Server host address' },
{ path: 'server.port', description: 'Server port number' },
{ path: 'agent', description: 'Agent configuration' },
{ path: 'agent.defaults', description: 'Agent defaults' },
{ path: 'agent.defaults.workspace', description: 'Default workspace path' },
{ path: 'agent.defaults.default_model', description: 'Default model name' },
{ path: 'llm', description: 'LLM configuration' },
{ path: 'llm.default_provider', description: 'Default LLM provider' },
{ path: 'llm.default_model', description: 'Default LLM model' },
];
/**
* Default configuration values
*/
const DEFAULT_CONFIG: Partial<OpenFangConfig> = {
server: {
host: '127.0.0.1',
port: 50051,
websocket_port: 50051,
websocket_path: '/ws',
api_version: 'v1',
},
agent: {
defaults: {
workspace: '~/.openfang/workspace',
default_model: 'gpt-4',
},
},
llm: {
default_provider: 'openai',
default_model: 'gpt-4',
},
};
/**
* Configuration parser and validator
*/
export const configParser = {
/**
* Parse TOML content into an OpenFang configuration object
*
* @param content - The TOML content to parse
* @param envVars - Optional environment variables for resolution
* @returns The parsed configuration object
* @throws ConfigParseError if parsing fails
*
* @example
* ```typescript
* const config = configParser.parseConfig(tomlContent, { OPENAI_API_KEY: 'sk-...' });
* ```
*/
parseConfig: (content: string, envVars?: Record<string, string | undefined>): OpenFangConfig => {
try {
// First resolve environment variables
const resolved = tomlUtils.resolveEnvVars(content, envVars);
// Parse TOML
const parsed = tomlUtils.parse<OpenFangConfig>(resolved);
return parsed;
} catch (error) {
if (error instanceof TomlParseError) {
throw new ConfigParseError(`Failed to parse configuration: ${error.message}`, error);
}
throw new ConfigParseError(
`Unexpected error parsing configuration: ${error instanceof Error ? error.message : String(error)}`,
error
);
}
},
/**
* Validate an OpenFang configuration object
*
* @param config - The configuration object to validate
* @returns Validation result with errors and warnings
*
* @example
* ```typescript
* const result = configParser.validateConfig(parsedConfig);
* if (!result.valid) {
* console.error('Config errors:', result.errors);
* }
* ```
*/
validateConfig: (config: unknown): ConfigValidationResult => {
const errors: ConfigValidationError[] = [];
const warnings: ConfigValidationWarning[] = [];
// Basic type check
if (typeof config !== 'object' || config === null) {
errors.push({
path: '',
message: 'Configuration must be a non-null object',
severity: 'error',
});
return { valid: false, errors, warnings };
}
const cfg = config as Record<string, unknown>;
// Check required fields
for (const { path, description } of REQUIRED_FIELDS) {
const value = getNestedValue(cfg, path);
if (value === undefined) {
errors.push({
path,
message: `Missing required field: ${description}`,
severity: 'error',
});
}
}
// Validate server configuration
if (cfg.server && typeof cfg.server === 'object') {
const server = cfg.server as ServerConfig;
if (typeof server.port === 'number' && (server.port < 1 || server.port > 65535)) {
errors.push({
path: 'server.port',
message: 'Port must be between 1 and 65535',
severity: 'error',
});
}
if (typeof server.host === 'string' && server.host.length === 0) {
errors.push({
path: 'server.host',
message: 'Host cannot be empty',
severity: 'error',
});
}
}
// Validate agent configuration
if (cfg.agent && typeof cfg.agent === 'object') {
const agent = cfg.agent as AgentSectionConfig;
if (agent.defaults) {
if (typeof agent.defaults.workspace === 'string' && agent.defaults.workspace.length === 0) {
warnings.push({
path: 'agent.defaults.workspace',
message: 'Workspace path is empty',
severity: 'warning',
});
}
if (typeof agent.defaults.default_model === 'string' && agent.defaults.default_model.length === 0) {
errors.push({
path: 'agent.defaults.default_model',
message: 'Default model cannot be empty',
severity: 'error',
});
}
}
}
// Validate LLM configuration
if (cfg.llm && typeof cfg.llm === 'object') {
const llm = cfg.llm as LLMConfig;
if (typeof llm.default_provider === 'string' && llm.default_provider.length === 0) {
errors.push({
path: 'llm.default_provider',
message: 'Default provider cannot be empty',
severity: 'error',
});
}
if (typeof llm.default_model === 'string' && llm.default_model.length === 0) {
errors.push({
path: 'llm.default_model',
message: 'Default model cannot be empty',
severity: 'error',
});
}
}
return {
valid: errors.length === 0,
errors,
warnings,
};
},
/**
* Parse and validate configuration in one step
*
* @param content - The TOML content to parse
* @param envVars - Optional environment variables for resolution
* @returns The parsed and validated configuration
* @throws ConfigParseError if parsing fails
* @throws ConfigValidationFailedError if validation fails
*
* @example
* ```typescript
* const config = configParser.parseAndValidate(tomlContent);
* ```
*/
parseAndValidate: (
content: string,
envVars?: Record<string, string | undefined>
): OpenFangConfig => {
const config = configParser.parseConfig(content, envVars);
const result = configParser.validateConfig(config);
if (!result.valid) {
throw new ConfigValidationFailedError(
`Configuration validation failed: ${result.errors.map((e) => e.message).join(', ')}`,
result.errors
);
}
return config;
},
/**
* Serialize a configuration object to TOML format
*
* @param config - The configuration object to serialize
* @returns The TOML string
*
* @example
* ```typescript
* const toml = configParser.stringifyConfig(config);
* ```
*/
stringifyConfig: (config: OpenFangConfig): string => {
return tomlUtils.stringify(config as unknown as Record<string, unknown>);
},
/**
* Merge partial configuration with defaults
*
* @param config - Partial configuration to merge
* @returns Complete configuration with defaults applied
*
* @example
* ```typescript
* const fullConfig = configParser.mergeWithDefaults(partialConfig);
* ```
*/
mergeWithDefaults: (config: Partial<OpenFangConfig>): OpenFangConfig => {
return deepMerge(DEFAULT_CONFIG, config) as unknown as OpenFangConfig;
},
/**
* Extract metadata from a TOML configuration file
*
* @param content - The TOML content
* @param filePath - The file path
* @returns Configuration file metadata
*
* @example
* ```typescript
* const metadata = configParser.extractMetadata(tomlContent, '/path/to/config.toml');
* console.log('Env vars needed:', metadata.envVars);
* ```
*/
extractMetadata: (content: string, filePath: string): ConfigFileMetadata => {
const envVars = tomlUtils.extractEnvVarNames(content);
const hasUnresolvedEnvVars = tomlUtils.hasUnresolvedEnvVars(content);
return {
path: filePath,
name: filePath.split('/').pop() || filePath,
envVars,
hasUnresolvedEnvVars,
};
},
/**
* Get default configuration
*
* @returns Default OpenFang configuration
*/
getDefaults: (): OpenFangConfig => {
return JSON.parse(JSON.stringify(DEFAULT_CONFIG)) as OpenFangConfig;
},
/**
* Check if a configuration object is valid
*
* @param config - The configuration to check
* @returns Type guard for OpenFangConfig
*/
isOpenFangConfig: (config: unknown): config is OpenFangConfig => {
const result = configParser.validateConfig(config);
return result.valid;
},
};
/**
* Helper function to get a nested value from an object using dot-notation path
*/
function getNestedValue(obj: Record<string, unknown>, path: string): unknown {
const parts = path.split('.');
let current: unknown = obj;
for (const part of parts) {
if (current === null || current === undefined) {
return undefined;
}
if (typeof current !== 'object') {
return undefined;
}
current = (current as Record<string, unknown>)[part];
}
return current;
}
/**
* Helper function to deep merge two objects
*/
function deepMerge<T extends Record<string, unknown>>(
target: Partial<T>,
source: Partial<T>
): Partial<T> {
const result = { ...target };
for (const key of Object.keys(source) as (keyof T)[]) {
const sourceValue = source[key];
const targetValue = target[key];
if (
sourceValue !== undefined &&
typeof sourceValue === 'object' &&
sourceValue !== null &&
!Array.isArray(sourceValue) &&
targetValue !== undefined &&
typeof targetValue === 'object' &&
targetValue !== null &&
!Array.isArray(targetValue)
) {
result[key] = deepMerge(
targetValue as Record<string, unknown>,
sourceValue as Record<string, unknown>
) as T[keyof T];
} else if (sourceValue !== undefined) {
// Safe assignment: sourceValue is typed as Partial<T>[keyof T], result[key] expects T[keyof T]
result[key] = sourceValue as T[keyof T];
}
}
return result;
}
export default configParser;

View File

@@ -1241,6 +1241,16 @@ export class GatewayClient {
return this.restGet(`/api/audit/logs?${params}`);
}
/** Verify audit log chain for a specific log entry */
async verifyAuditLogChain(logId: string): Promise<{
valid: boolean;
chain_depth?: number;
root_hash?: string;
broken_at_index?: number;
}> {
return this.restGet(`/api/audit/verify/${logId}`);
}
// === OpenFang Security API ===
/** Get security status */

View File

@@ -0,0 +1,542 @@
/**
* Request Helper Module
*
* Provides request timeout, automatic retry with exponential backoff,
* and request cancellation support for API clients.
*
* @module lib/request-helper
*/
// === Configuration Types ===
export interface RequestConfig {
/** Timeout in milliseconds, default 30000 */
timeout?: number;
/** Number of retry attempts, default 3 */
retries?: number;
/** Base retry delay in milliseconds, default 1000 (exponential backoff applied) */
retryDelay?: number;
/** HTTP status codes that trigger retry, default [408, 429, 500, 502, 503, 504] */
retryOn?: number[];
/** Maximum retry delay cap in milliseconds, default 30000 */
maxRetryDelay?: number;
}
export const DEFAULT_REQUEST_CONFIG: Required<RequestConfig> = {
timeout: 30000,
retries: 3,
retryDelay: 1000,
retryOn: [408, 429, 500, 502, 503, 504],
maxRetryDelay: 30000,
};
// === Error Types ===
export class RequestError extends Error {
constructor(
message: string,
public readonly status: number,
public readonly statusText: string,
public readonly responseBody?: string
) {
super(message);
this.name = 'RequestError';
}
/** Check if error is retryable based on status code */
isRetryable(retryCodes: number[] = DEFAULT_REQUEST_CONFIG.retryOn): boolean {
return retryCodes.includes(this.status);
}
/** Check if error is a timeout */
isTimeout(): boolean {
return this.status === 408 || this.message.includes('timeout');
}
/** Check if error is an authentication error (should NOT retry) */
isAuthError(): boolean {
return this.status === 401 || this.status === 403;
}
}
export class RequestCancelledError extends Error {
constructor(message: string = 'Request cancelled') {
super(message);
this.name = 'RequestCancelledError';
}
}
// === Helper Functions ===
function delay(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
/**
* Calculate exponential backoff delay with jitter
* @param baseDelay Base delay in ms
* @param attempt Current attempt number (0-indexed)
* @param maxDelay Maximum delay cap
* @returns Delay in milliseconds
*/
function calculateBackoff(
baseDelay: number,
attempt: number,
maxDelay: number = DEFAULT_REQUEST_CONFIG.maxRetryDelay
): number {
// Exponential backoff: baseDelay * 2^attempt
const exponentialDelay = baseDelay * Math.pow(2, attempt);
// Cap at maxDelay
const cappedDelay = Math.min(exponentialDelay, maxDelay);
// Add jitter (0-25% of delay) to prevent thundering herd
const jitter = cappedDelay * 0.25 * Math.random();
return Math.floor(cappedDelay + jitter);
}
// === Request with Retry ===
export interface RequestWithRetryOptions extends RequestInit {
/** Request configuration for timeout and retry */
config?: RequestConfig;
}
/**
* Execute a fetch request with timeout and automatic retry support.
*
* Features:
* - Configurable timeout with AbortController
* - Automatic retry with exponential backoff
* - Configurable retry status codes
* - Jitter to prevent thundering herd
*
* @param url Request URL
* @param options Fetch options + request config
* @param config Request configuration (timeout, retries, etc.)
* @returns Promise<Response>
* @throws RequestError on failure after all retries exhausted
* @throws RequestCancelledError if request was cancelled
*/
export async function requestWithRetry(
url: string,
options: RequestInit = {},
config: RequestConfig = {}
): Promise<Response> {
const {
timeout = DEFAULT_REQUEST_CONFIG.timeout,
retries = DEFAULT_REQUEST_CONFIG.retries,
retryDelay = DEFAULT_REQUEST_CONFIG.retryDelay,
retryOn = DEFAULT_REQUEST_CONFIG.retryOn,
} = config;
let lastError: RequestError | null = null;
let responseBody = '';
for (let attempt = 0; attempt <= retries; attempt++) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
try {
const response = await fetch(url, {
...options,
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
// Try to read response body for error details
try {
responseBody = await response.text();
} catch {
responseBody = '';
}
const error = new RequestError(
`Request failed: ${response.status} ${response.statusText}`,
response.status,
response.statusText,
responseBody
);
// Check if we should retry
if (retryOn.includes(response.status) && attempt < retries) {
const backoff = calculateBackoff(retryDelay, attempt);
console.warn(
`[RequestHelper] Request failed (${response.status}), ` +
`retrying in ${backoff}ms (attempt ${attempt + 1}/${retries})`
);
await delay(backoff);
continue;
}
throw error;
}
// Success - return response
return response;
} catch (error) {
clearTimeout(timeoutId);
// Re-throw RequestError (already formatted)
if (error instanceof RequestError) {
lastError = error;
// Check if we should retry
if (error.isRetryable(retryOn) && attempt < retries) {
const backoff = calculateBackoff(retryDelay, attempt);
console.warn(
`[RequestHelper] Request error (${error.status}), ` +
`retrying in ${backoff}ms (attempt ${attempt + 1}/${retries})`
);
await delay(backoff);
continue;
}
throw error;
}
// Handle AbortError (timeout)
if (error instanceof Error && error.name === 'AbortError') {
const timeoutError = new RequestError(
`Request timeout after ${timeout}ms`,
408,
'Request Timeout'
);
// Retry on timeout
if (attempt < retries) {
const backoff = calculateBackoff(retryDelay, attempt);
console.warn(
`[RequestHelper] Request timed out, ` +
`retrying in ${backoff}ms (attempt ${attempt + 1}/${retries})`
);
await delay(backoff);
lastError = timeoutError;
continue;
}
throw timeoutError;
}
// Handle cancellation
if (error instanceof RequestCancelledError) {
throw error;
}
// Unknown error - wrap and throw
throw new RequestError(
error instanceof Error ? error.message : 'Unknown error',
0,
'Unknown Error',
error instanceof Error ? error.stack : String(error)
);
}
}
// All retries exhausted
throw lastError || new RequestError('All retry attempts exhausted', 0, 'Retry Exhausted');
}
/**
* Execute a request and parse JSON response.
* Combines requestWithRetry with JSON parsing.
*
* @param url Request URL
* @param options Fetch options
* @param config Request configuration
* @returns Parsed JSON response
*/
export async function requestJson<T = unknown>(
url: string,
options: RequestInit = {},
config: RequestConfig = {}
): Promise<T> {
const response = await requestWithRetry(url, options, config);
try {
return await response.json();
} catch (error) {
throw new RequestError(
`Failed to parse JSON response: ${error instanceof Error ? error.message : 'Unknown error'}`,
0,
'Parse Error',
await response.text().catch(() => '')
);
}
}
// === Request Manager (Cancellation Support) ===
/**
* Manages multiple concurrent requests with cancellation support.
* Provides centralized control over request lifecycle.
*/
export class RequestManager {
private controllers = new Map<string, AbortController>();
private requestConfigs = new Map<string, RequestConfig>();
/**
* Create a new request with an ID for tracking.
* Returns the AbortController for signal attachment.
*
* @param id Unique request identifier
* @param config Optional request configuration
* @returns AbortController for the request
*/
createRequest(id: string, config?: RequestConfig): AbortController {
// Cancel existing request with same ID
if (this.controllers.has(id)) {
this.cancelRequest(id);
}
const controller = new AbortController();
this.controllers.set(id, controller);
if (config) {
this.requestConfigs.set(id, config);
}
return controller;
}
/**
* Execute a managed request with automatic tracking.
* The request will be automatically removed when complete.
*
* @param id Unique request identifier
* @param url Request URL
* @param options Fetch options
* @param config Request configuration
* @returns Response promise
*/
async executeManaged(
id: string,
url: string,
options: RequestInit = {},
config: RequestConfig = {}
): Promise<Response> {
const controller = this.createRequest(id, config);
try {
const response = await requestWithRetry(
url,
{
...options,
signal: controller.signal,
},
config
);
// Clean up on success
this.controllers.delete(id);
this.requestConfigs.delete(id);
return response;
} catch (error) {
// Clean up on error
this.controllers.delete(id);
this.requestConfigs.delete(id);
throw error;
}
}
/**
* Execute a managed JSON request with automatic tracking.
*
* @param id Unique request identifier
* @param url Request URL
* @param options Fetch options
* @param config Request configuration
* @returns Parsed JSON response
*/
async executeManagedJson<T = unknown>(
id: string,
url: string,
options: RequestInit = {},
config: RequestConfig = {}
): Promise<T> {
const response = await this.executeManaged(id, url, options, config);
try {
return await response.json();
} catch (error) {
throw new RequestError(
`Failed to parse JSON response: ${error instanceof Error ? error.message : 'Unknown error'}`,
0,
'Parse Error',
await response.text().catch(() => '')
);
}
}
/**
* Cancel a specific request by ID.
*
* @param id Request identifier
* @returns true if request was cancelled, false if not found
*/
cancelRequest(id: string): boolean {
const controller = this.controllers.get(id);
if (controller) {
controller.abort();
this.controllers.delete(id);
this.requestConfigs.delete(id);
return true;
}
return false;
}
/**
* Check if a request is currently in progress.
*
* @param id Request identifier
* @returns true if request is active
*/
isRequestActive(id: string): boolean {
return this.controllers.has(id);
}
/**
* Get all active request IDs.
*
* @returns Array of active request IDs
*/
getActiveRequestIds(): string[] {
return Array.from(this.controllers.keys());
}
/**
* Cancel all active requests.
*/
cancelAll(): void {
this.controllers.forEach((controller, id) => {
controller.abort();
console.log(`[RequestManager] Cancelled request: ${id}`);
});
this.controllers.clear();
this.requestConfigs.clear();
}
/**
* Get the number of active requests.
*/
get activeCount(): number {
return this.controllers.size;
}
}
// === Default Request Manager Instance ===
/**
* Global request manager instance for application-wide request tracking.
* Use this for simple cases; create new instances for isolated contexts.
*/
export const globalRequestManager = new RequestManager();
// === Convenience Functions ===
/**
* Create a GET request with retry support.
*/
export async function get(
url: string,
headers?: HeadersInit,
config?: RequestConfig
): Promise<Response> {
return requestWithRetry(url, { method: 'GET', headers }, config);
}
/**
* Create a POST request with retry support.
*/
export async function post(
url: string,
body?: unknown,
headers?: HeadersInit,
config?: RequestConfig
): Promise<Response> {
return requestWithRetry(
url,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
...headers,
},
body: body ? JSON.stringify(body) : undefined,
},
config
);
}
/**
* Create a PUT request with retry support.
*/
export async function put(
url: string,
body?: unknown,
headers?: HeadersInit,
config?: RequestConfig
): Promise<Response> {
return requestWithRetry(
url,
{
method: 'PUT',
headers: {
'Content-Type': 'application/json',
...headers,
},
body: body ? JSON.stringify(body) : undefined,
},
config
);
}
/**
* Create a DELETE request with retry support.
*/
export async function del(
url: string,
headers?: HeadersInit,
config?: RequestConfig
): Promise<Response> {
return requestWithRetry(url, { method: 'DELETE', headers }, config);
}
/**
* Create a PATCH request with retry support.
*/
export async function patch(
url: string,
body?: unknown,
headers?: HeadersInit,
config?: RequestConfig
): Promise<Response> {
return requestWithRetry(
url,
{
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
...headers,
},
body: body ? JSON.stringify(body) : undefined,
},
config
);
}
export default {
requestWithRetry,
requestJson,
RequestManager,
globalRequestManager,
RequestError,
RequestCancelledError,
get,
post,
put,
del,
patch,
DEFAULT_REQUEST_CONFIG,
};

View File

@@ -0,0 +1,181 @@
/**
* ZCLAW Secure Storage
*
* Provides secure credential storage using the OS keyring/keychain.
* Falls back to localStorage when not running in Tauri or if keyring is unavailable.
*
* Platform support:
* - Windows: DPAPI
* - macOS: Keychain
* - Linux: Secret Service API (gnome-keyring, kwallet, etc.)
*/
import { invoke } from '@tauri-apps/api/core';
import { isTauriRuntime } from './tauri-gateway';
// Cache for keyring availability check
let keyringAvailable: boolean | null = null;
/**
* Check if secure storage (keyring) is available
*/
export async function isSecureStorageAvailable(): Promise<boolean> {
if (!isTauriRuntime()) {
return false;
}
// Use cached result if available
if (keyringAvailable !== null) {
return keyringAvailable;
}
try {
keyringAvailable = await invoke<boolean>('secure_store_is_available');
return keyringAvailable;
} catch (error) {
console.warn('[SecureStorage] Keyring not available:', error);
keyringAvailable = false;
return false;
}
}
/**
* Secure storage interface
* Uses OS keyring when available, falls back to localStorage
*/
export const secureStorage = {
/**
* Store a value securely
* @param key - Storage key
* @param value - Value to store
*/
async set(key: string, value: string): Promise<void> {
const trimmedValue = value.trim();
if (await isSecureStorageAvailable()) {
try {
if (trimmedValue) {
await invoke('secure_store_set', { key, value: trimmedValue });
} else {
await invoke('secure_store_delete', { key });
}
// Also write to localStorage as backup/migration support
writeLocalStorageBackup(key, trimmedValue);
return;
} catch (error) {
console.warn('[SecureStorage] Failed to use keyring, falling back to localStorage:', error);
}
}
// Fallback to localStorage
writeLocalStorageBackup(key, trimmedValue);
},
/**
* Retrieve a value from secure storage
* @param key - Storage key
* @returns Stored value or null if not found
*/
async get(key: string): Promise<string | null> {
if (await isSecureStorageAvailable()) {
try {
const value = await invoke<string>('secure_store_get', { key });
if (value !== null && value !== undefined && value !== '') {
return value;
}
// If keyring returned empty, try localStorage fallback for migration
return readLocalStorageBackup(key);
} catch (error) {
console.warn('[SecureStorage] Failed to read from keyring, trying localStorage:', error);
}
}
// Fallback to localStorage
return readLocalStorageBackup(key);
},
/**
* Delete a value from secure storage
* @param key - Storage key
*/
async delete(key: string): Promise<void> {
if (await isSecureStorageAvailable()) {
try {
await invoke('secure_store_delete', { key });
} catch (error) {
console.warn('[SecureStorage] Failed to delete from keyring:', error);
}
}
// Always clear localStorage backup
clearLocalStorageBackup(key);
},
/**
* Check if secure storage is being used (vs localStorage fallback)
*/
async isUsingKeyring(): Promise<boolean> {
return isSecureStorageAvailable();
},
};
/**
* localStorage backup functions for migration and fallback
*/
function writeLocalStorageBackup(key: string, value: string): void {
try {
if (value) {
localStorage.setItem(key, value);
} else {
localStorage.removeItem(key);
}
} catch {
// Ignore localStorage failures
}
}
function readLocalStorageBackup(key: string): string | null {
try {
return localStorage.getItem(key);
} catch {
return null;
}
}
function clearLocalStorageBackup(key: string): void {
try {
localStorage.removeItem(key);
} catch {
// Ignore localStorage failures
}
}
/**
* Synchronous versions for compatibility with existing code
* These use localStorage only and are provided for gradual migration
*/
export const secureStorageSync = {
/**
* Synchronously get a value from localStorage (for migration only)
* @deprecated Use async secureStorage.get() instead
*/
get(key: string): string | null {
return readLocalStorageBackup(key);
},
/**
* Synchronously set a value in localStorage (for migration only)
* @deprecated Use async secureStorage.set() instead
*/
set(key: string, value: string): void {
writeLocalStorageBackup(key, value);
},
/**
* Synchronously delete a value from localStorage (for migration only)
* @deprecated Use async secureStorage.delete() instead
*/
delete(key: string): void {
clearLocalStorageBackup(key);
},
};

View File

@@ -0,0 +1,186 @@
/**
* TOML Utility Functions
*
* Provides TOML parsing and serialization capabilities for OpenFang configuration files.
* Supports environment variable interpolation in the format ${VAR_NAME}.
*
* @module toml-utils
*/
import TOML from 'smol-toml';
/**
* Error class for TOML parsing errors
*/
export class TomlParseError extends Error {
constructor(
message: string,
public readonly cause?: unknown
) {
super(message);
this.name = 'TomlParseError';
}
}
/**
* Error class for TOML serialization errors
*/
export class TomlStringifyError extends Error {
constructor(
message: string,
public readonly cause?: unknown
) {
super(message);
this.name = 'TomlStringifyError';
}
}
/**
* TOML utility functions for parsing and serializing configuration files
*/
export const tomlUtils = {
/**
* Parse a TOML string into a JavaScript object
*
* @param content - The TOML string to parse
* @returns The parsed JavaScript object
* @throws TomlParseError if the TOML content is invalid
*
* @example
* ```typescript
* const config = tomlUtils.parse(`
* [server]
* host = "127.0.0.1"
* port = 4200
* `);
* // config = { server: { host: "127.0.0.1", port: 4200 } }
* ```
*/
parse: <T = Record<string, unknown>>(content: string): T => {
try {
return TOML.parse(content) as T;
} catch (error) {
console.error('[TOML] Parse error:', error);
throw new TomlParseError(
`TOML parse error: ${error instanceof Error ? error.message : String(error)}`,
error
);
}
},
/**
* Serialize a JavaScript object to a TOML string
*
* @param data - The JavaScript object to serialize
* @returns The TOML string representation
* @throws TomlStringifyError if the object cannot be serialized to TOML
*
* @example
* ```typescript
* const toml = tomlUtils.stringify({
* server: { host: "127.0.0.1", port: 4200 }
* });
* ```
*/
stringify: (data: Record<string, unknown>): string => {
try {
return TOML.stringify(data);
} catch (error) {
console.error('[TOML] Stringify error:', error);
throw new TomlStringifyError(
`TOML stringify error: ${error instanceof Error ? error.message : String(error)}`,
error
);
}
},
/**
* Resolve environment variables in TOML content
*
* Replaces ${VAR_NAME} patterns with the corresponding environment variable values.
* If the environment variable is not set, it's replaced with an empty string.
*
* Note: In browser/Tauri context, this function has limited access to environment
* variables. For full resolution, use the Tauri backend to read env vars.
*
* @param content - The TOML content with potential ${VAR_NAME} patterns
* @param envVars - Optional object containing environment variables (for testing or Tauri-provided values)
* @returns The content with environment variables resolved
*
* @example
* ```typescript
* const content = 'api_key = "${OPENAI_API_KEY}"';
* const resolved = tomlUtils.resolveEnvVars(content, { OPENAI_API_KEY: 'sk-...' });
* // resolved = 'api_key = "sk-..."'
* ```
*/
resolveEnvVars: (
content: string,
envVars?: Record<string, string | undefined>
): string => {
return content.replace(/\$\{([^}]+)\}/g, (_, varName: string) => {
// If envVars provided, use them; otherwise try to access from window or return empty
if (envVars) {
return envVars[varName] ?? '';
}
// In browser context, we can't access process.env directly
// This will be handled by passing envVars from Tauri backend
console.warn(
`[TOML] Environment variable ${varName} not resolved - no envVars provided`
);
return '';
});
},
/**
* Parse TOML content with environment variable resolution
*
* Convenience method that combines resolveEnvVars and parse.
*
* @param content - The TOML content with potential ${VAR_NAME} patterns
* @param envVars - Optional object containing environment variables
* @returns The parsed and resolved JavaScript object
*
* @example
* ```typescript
* const config = tomlUtils.parseWithEnvVars(tomlContent, {
* ZHIPU_API_KEY: 'your-api-key'
* });
* ```
*/
parseWithEnvVars: <T = Record<string, unknown>>(
content: string,
envVars?: Record<string, string | undefined>
): T => {
const resolved = tomlUtils.resolveEnvVars(content, envVars);
return tomlUtils.parse<T>(resolved);
},
/**
* Check if a string contains unresolved environment variable placeholders
*
* @param content - The content to check
* @returns true if there are unresolved ${VAR_NAME} patterns
*/
hasUnresolvedEnvVars: (content: string): boolean => {
return /\$\{[^}]+\}/.test(content);
},
/**
* Extract environment variable names from TOML content
*
* @param content - The TOML content to scan
* @returns Array of environment variable names found
*/
extractEnvVarNames: (content: string): string[] => {
const matches = content.matchAll(/\$\{([^}]+)\}/g);
const names = new Set<string>();
for (const match of matches) {
names.add(match[1]);
}
return Array.from(names);
},
};
export default tomlUtils;