/** * gateway-storage.ts - Gateway URL/Token Storage & Normalization * * Extracted from gateway-client.ts for modularity. * Manages WSS configuration, URL normalization, and * 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 === /** * Whether to use WSS (WebSocket Secure) instead of WS. * - Production: defaults to WSS for security * - Development: defaults to WS for convenience * - Override: set VITE_USE_WSS=false to force WS in production */ const USE_WSS = import.meta.env.VITE_USE_WSS !== 'false' && import.meta.env.PROD; /** * Default protocol based on WSS configuration. */ const DEFAULT_WS_PROTOCOL = USE_WSS ? 'wss://' : 'ws://'; /** * Check if a URL points to localhost. */ export function isLocalhost(url: string): boolean { try { const parsed = new URL(url); return parsed.hostname === 'localhost' || parsed.hostname === '127.0.0.1' || parsed.hostname === '[::1]'; } catch { return false; } } // === URL Constants === // OpenFang endpoints (port 50051 - actual running port) // Note: REST API uses relative path to leverage Vite proxy for CORS bypass export const DEFAULT_GATEWAY_URL = `${DEFAULT_WS_PROTOCOL}127.0.0.1:50051/ws`; export const REST_API_URL = ''; // Empty = use relative path (Vite proxy) export const FALLBACK_GATEWAY_URLS = [ DEFAULT_GATEWAY_URL, `${DEFAULT_WS_PROTOCOL}127.0.0.1:4200/ws`, ]; const GATEWAY_URL_STORAGE_KEY = 'zclaw_gateway_url'; const GATEWAY_TOKEN_STORAGE_KEY = 'zclaw_gateway_token'; // === URL Normalization === /** * Normalize a gateway URL to ensure correct protocol and path. * - Ensures ws:// or wss:// protocol based on configuration * - Ensures /ws path suffix * - Handles both localhost and IP addresses */ export function normalizeGatewayUrl(url: string): string { let normalized = url.trim(); // Remove trailing slashes except for protocol normalized = normalized.replace(/\/+$/, ''); // Ensure protocol if (!normalized.startsWith('ws://') && !normalized.startsWith('wss://')) { normalized = USE_WSS ? `wss://${normalized}` : `ws://${normalized}`; } // Ensure /ws path if (!normalized.endsWith('/ws')) { normalized = `${normalized}/ws`; } return normalized; } // === LocalStorage Helpers === export function getStoredGatewayUrl(): string { try { const stored = localStorage.getItem(GATEWAY_URL_STORAGE_KEY); return normalizeGatewayUrl(stored || DEFAULT_GATEWAY_URL); } catch { return DEFAULT_GATEWAY_URL; } } export function setStoredGatewayUrl(url: string): string { const normalized = normalizeGatewayUrl(url || DEFAULT_GATEWAY_URL); try { localStorage.setItem(GATEWAY_URL_STORAGE_KEY, normalized); } catch { /* ignore localStorage failures */ } return normalized; } /** * 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 { try { 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 ''; } } /** * 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 { 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); } } catch { /* ignore localStorage failures */ } return normalized; }