/** * 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; } } // === Port Constants === /** Default gRPC/HTTP port used by the ZCLAW kernel */ export const ZCLAW_GRPC_PORT = 50051; /** Legacy/alternative port used in development or older configurations */ export const ZCLAW_LEGACY_PORT = 4200; // === Connection Mode === /** * Determines how the client connects to the ZCLAW gateway. * - `rest`: Kernel exposes an HTTP REST API (gRPC-gateway). Used when the * URL contains a known kernel port. * - `ws`: Direct WebSocket connection to the kernel. */ export type ConnectionMode = 'rest' | 'ws'; /** * Decide the connection mode based on the gateway URL. * * When the URL contains a known kernel port (gRPC or legacy), the client * routes requests through the REST adapter instead of opening a raw * WebSocket. */ export function detectConnectionMode(url: string): ConnectionMode { if (url.includes(`:${ZCLAW_GRPC_PORT}`) || url.includes(`:${ZCLAW_LEGACY_PORT}`)) { return 'rest'; } return 'ws'; } // === URL Constants === // ZCLAW 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:${ZCLAW_GRPC_PORT}/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:${ZCLAW_LEGACY_PORT}/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; }