// Global error handler to prevent page refreshes window.addEventListener('error', function (e) { console.error('Global error caught:', e.error); console.error('Error message:', e.message); console.error('Error filename:', e.filename); console.error('Error line:', e.lineno); e.preventDefault(); // Prevent default browser error handling return true; // Prevent page refresh }); window.addEventListener('unhandledrejection', function (e) { console.error('Unhandled promise rejection:', e.reason); e.preventDefault(); // Prevent default browser error handling return true; // Prevent page refresh }); // Global state let nlLite = null; let userPubkey = null; let isLoggedIn = false; let currentConfig = null; // Global subscription state let relayPool = null; let subscriptionId = null; let isSubscribed = false; // Flag to prevent multiple simultaneous subscriptions // Relay connection state let relayInfo = null; let isRelayConnected = false; let relayPubkey = null; // Simple relay URL object (replaces DOM element) let relayConnectionUrl = { value: '' }; // Database statistics auto-refresh let statsAutoRefreshInterval = null; let countdownInterval = null; let countdownSeconds = 10; // SQL Query state let pendingSqlQueries = new Map(); // Real-time event rate chart let eventRateChart = null; let previousTotalEvents = 0; // Track previous total for rate calculation // DOM elements const loginModal = document.getElementById('login-modal'); const loginModalContainer = document.getElementById('login-modal-container'); const profileArea = document.getElementById('profile-area'); const headerUserImage = document.getElementById('header-user-image'); const headerUserName = document.getElementById('header-user-name'); const logoutDropdown = document.getElementById('logout-dropdown'); const logoutBtn = document.getElementById('logout-btn'); // Legacy elements (kept for backward compatibility) const persistentUserName = document.getElementById('persistent-user-name'); const persistentUserPubkey = document.getElementById('persistent-user-pubkey'); const persistentUserAbout = document.getElementById('persistent-user-about'); const persistentUserDetails = document.getElementById('persistent-user-details'); const fetchConfigBtn = document.getElementById('fetch-config-btn'); const configDisplay = document.getElementById('config-display'); const configTableBody = document.getElementById('config-table-body'); // NIP-17 DM elements const dmOutbox = document.getElementById('dm-outbox'); const dmInbox = document.getElementById('dm-inbox'); const sendDmBtn = document.getElementById('send-dm-btn'); // Utility functions function log(message, type = 'INFO') { const timestamp = new Date().toISOString().split('T')[1].split('.')[0]; const logMessage = `${timestamp} [${type}]: ${message}`; // Always log to browser console so we don't lose logs on refresh console.log(logMessage); // UI logging removed - using console only } // Utility functions function log(message, type = 'INFO') { const timestamp = new Date().toISOString().split('T')[1].split('.')[0]; const logMessage = `${timestamp} [${type}]: ${message}`; // Always log to browser console so we don't lose logs on refresh console.log(logMessage); // UI logging removed - using console only } // NIP-59 helper: randomize created_at to thwart time-analysis (past 2 days) function randomNow() { const TWO_DAYS = 2 * 24 * 60 * 60; // 172800 seconds const now = Math.round(Date.now() / 1000); return Math.round(now - Math.random() * TWO_DAYS); } // Safe JSON parse with error handling function safeJsonParse(jsonString) { try { return JSON.parse(jsonString); } catch (error) { console.error('JSON parse error:', error); return null; } } // ================================ // NIP-11 RELAY CONNECTION FUNCTIONS // ================================ // Convert WebSocket URL to HTTP URL for NIP-11 function wsToHttpUrl(wsUrl) { if (wsUrl.startsWith('ws://')) { return wsUrl.replace('ws://', 'http://'); } else if (wsUrl.startsWith('wss://')) { return wsUrl.replace('wss://', 'https://'); } return wsUrl; } // Fetch relay information using NIP-11 async function fetchRelayInfo(relayUrl) { try { log(`Fetching NIP-11 relay info from: ${relayUrl}`, 'INFO'); // Convert WebSocket URL to HTTP URL const httpUrl = wsToHttpUrl(relayUrl); // Make HTTP request with NIP-11 headers const response = await fetch(httpUrl, { method: 'GET', headers: { 'Accept': 'application/nostr+json', 'User-Agent': 'C-Relay-Admin-API/1.0' }, timeout: 10000 // 10 second timeout }); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } const contentType = response.headers.get('content-type'); if (!contentType || !contentType.includes('application/nostr+json')) { throw new Error(`Invalid content type: ${contentType}. Expected application/nostr+json`); } const relayInfo = await response.json(); // Log if relay info is empty (not configured yet) but don't throw error if (!relayInfo || Object.keys(relayInfo).length === 0) { log('Relay returned empty NIP-11 info - relay not configured yet, will use manual pubkey if provided', 'INFO'); // Return empty object - this is valid, caller will handle manual pubkey fallback return {}; } // Validate pubkey if present if (relayInfo.pubkey && !/^[0-9a-fA-F]{64}$/.test(relayInfo.pubkey)) { throw new Error(`Invalid relay pubkey format: ${relayInfo.pubkey}`); } log(`Successfully fetched relay info. Pubkey: ${relayInfo.pubkey ? relayInfo.pubkey.substring(0, 16) + '...' : 'not set'}`, 'INFO'); return relayInfo; } catch (error) { log(`Failed to fetch relay info: ${error.message}`, 'ERROR'); throw error; } } // Test WebSocket connection to relay async function testWebSocketConnection(wsUrl) { return new Promise((resolve, reject) => { try { log(`Testing WebSocket connection to: ${wsUrl}`, 'INFO'); const ws = new WebSocket(wsUrl); const timeout = setTimeout(() => { ws.close(); reject(new Error('WebSocket connection timeout (10s)')); }, 10000); ws.onopen = () => { clearTimeout(timeout); log('WebSocket connection successful', 'INFO'); ws.close(); resolve(true); }; ws.onerror = (error) => { clearTimeout(timeout); log(`WebSocket connection failed: ${error.message || 'Unknown error'}`, 'ERROR'); reject(new Error('WebSocket connection failed')); }; ws.onclose = (event) => { if (event.code !== 1000) { // 1000 = normal closure clearTimeout(timeout); reject(new Error(`WebSocket closed unexpectedly: ${event.code} ${event.reason}`)); } }; } catch (error) { log(`WebSocket test error: ${error.message}`, 'ERROR'); reject(error); } }); } // Check for existing authentication state with multiple API methods and retry logic async function checkExistingAuthWithRetries() { console.log('Starting authentication state detection with retry logic...'); const maxAttempts = 3; const delay = 200; // ms between attempts (reduced from 500ms) for (let attempt = 1; attempt <= maxAttempts; attempt++) { console.log(`Authentication detection attempt ${attempt}/${maxAttempts}`); try { // Method 1: Try window.NOSTR_LOGIN_LITE.getAuthState() if (window.NOSTR_LOGIN_LITE && typeof window.NOSTR_LOGIN_LITE.getAuthState === 'function') { console.log('Trying window.NOSTR_LOGIN_LITE.getAuthState()...'); const authState = window.NOSTR_LOGIN_LITE.getAuthState(); if (authState && authState.pubkey) { console.log('✅ Auth state found via NOSTR_LOGIN_LITE.getAuthState():', authState.pubkey); await restoreAuthenticationState(authState.pubkey); return true; } } // Method 2: Try nlLite.getPublicKey() if (nlLite && typeof nlLite.getPublicKey === 'function') { console.log('Trying nlLite.getPublicKey()...'); const pubkey = await nlLite.getPublicKey(); if (pubkey && pubkey.length === 64) { console.log('✅ Pubkey found via nlLite.getPublicKey():', pubkey); await restoreAuthenticationState(pubkey); return true; } } // Method 3: Try window.nostr.getPublicKey() (NIP-07) if (window.nostr && typeof window.nostr.getPublicKey === 'function') { console.log('Trying window.nostr.getPublicKey()...'); const pubkey = await window.nostr.getPublicKey(); if (pubkey && pubkey.length === 64) { console.log('✅ Pubkey found via window.nostr.getPublicKey():', pubkey); await restoreAuthenticationState(pubkey); return true; } } // Method 4: Check localStorage directly for NOSTR_LOGIN_LITE data const localStorageData = localStorage.getItem('NOSTR_LOGIN_LITE_DATA'); if (localStorageData) { try { const parsedData = JSON.parse(localStorageData); if (parsedData.pubkey) { console.log('✅ Pubkey found in localStorage:', parsedData.pubkey); await restoreAuthenticationState(parsedData.pubkey); return true; } } catch (parseError) { console.log('Failed to parse localStorage data:', parseError.message); } } console.log(`❌ Attempt ${attempt}: No authentication found via any method`); // Wait before next attempt (except for last attempt) if (attempt < maxAttempts) { await new Promise(resolve => setTimeout(resolve, delay)); } } catch (error) { console.log(`❌ Attempt ${attempt} failed:`, error.message); if (attempt < maxAttempts) { await new Promise(resolve => setTimeout(resolve, delay)); } } } console.log('🔍 Authentication detection completed - no existing auth found after all attempts'); return false; } // Helper function to restore authentication state async function restoreAuthenticationState(pubkey) { console.log('🔄 Restoring authentication state for pubkey:', pubkey); userPubkey = pubkey; isLoggedIn = true; // Show main interface and profile in header showProfileInHeader(); loadUserProfile(); // Automatically set up relay connection (but don't show admin sections yet) await setupAutomaticRelayConnection(); console.log('✅ Authentication state restored successfully'); } // Automatically set up relay connection based on current page URL async function setupAutomaticRelayConnection(showSections = false) { try { // Get the current page URL and convert to WebSocket URL const currentUrl = window.location.href; let relayUrl = ''; if (currentUrl.startsWith('https://')) { relayUrl = currentUrl.replace('https://', 'wss://'); } else if (currentUrl.startsWith('http://')) { relayUrl = currentUrl.replace('http://', 'ws://'); } else { // Fallback for development relayUrl = 'ws://localhost:8888'; } // Remove any path components to get just the base URL const url = new URL(relayUrl); relayUrl = `${url.protocol}//${url.host}`; // Set the relay URL relayConnectionUrl.value = relayUrl; console.log('🔗 Auto-setting relay URL to:', relayUrl); // Fetch relay info to get pubkey try { const httpUrl = relayUrl.replace('ws', 'http').replace('wss', 'https'); const relayInfo = await fetchRelayInfo(httpUrl); if (relayInfo && relayInfo.pubkey) { relayPubkey = relayInfo.pubkey; console.log('🔑 Auto-fetched relay pubkey:', relayPubkey.substring(0, 16) + '...'); } else { // Use fallback pubkey relayPubkey = '4f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa'; console.log('⚠️ Using fallback relay pubkey'); } } catch (error) { console.log('⚠️ Could not fetch relay info, using fallback pubkey:', error.message); relayPubkey = '4f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa'; } // Initialize relay pool for admin API communication if (!relayPool) { relayPool = new window.NostrTools.SimplePool(); console.log('🔌 Initialized SimplePool for admin API communication'); } // Set up subscription to receive admin API responses await subscribeToConfiguration(); console.log('📡 Subscription established for admin API responses'); // Mark as connected isRelayConnected = true; // Update relay info in header updateRelayInfoInHeader(); // Only show admin sections if explicitly requested if (showSections) { updateAdminSectionsVisibility(); } console.log('✅ Automatic relay connection setup complete'); } catch (error) { console.error('❌ Failed to setup automatic relay connection:', error); // Still mark as connected to allow basic functionality isRelayConnected = true; if (showSections) { updateAdminSectionsVisibility(); } } } // Legacy function for backward compatibility async function checkExistingAuth() { return await checkExistingAuthWithRetries(); } // Initialize NOSTR_LOGIN_LITE async function initializeApp() { try { await window.NOSTR_LOGIN_LITE.init({ theme: 'default', methods: { extension: true, local: true, seedphrase: true, readonly: true, connect: true, remote: true, otp: false }, floatingTab: { enabled: false } }); nlLite = window.NOSTR_LOGIN_LITE; console.log('Nostr login system initialized'); // Check for existing authentication state after initialization const wasAlreadyLoggedIn = await checkExistingAuth(); if (wasAlreadyLoggedIn) { console.log('User was already logged in, showing profile in header'); showProfileInHeader(); // Show admin sections since user is already authenticated and relay is connected updateAdminSectionsVisibility(); } else { console.log('No existing authentication found, showing login modal'); showLoginModal(); } // Listen for authentication events window.addEventListener('nlMethodSelected', handleAuthEvent); window.addEventListener('nlLogout', handleLogoutEvent); } catch (error) { console.log('Failed to initialize Nostr login: ' + error.message); } } // Handle authentication events function handleAuthEvent(event) { const { pubkey, method, error } = event.detail; if (method && pubkey) { userPubkey = pubkey; isLoggedIn = true; console.log(`Login successful! Method: ${method}`); console.log(`Public key: ${pubkey}`); // Hide login modal and show profile in header hideLoginModal(); showProfileInHeader(); loadUserProfile(); // Automatically set up relay connection and show admin sections setupAutomaticRelayConnection(true); // Auto-enable monitoring when admin logs in autoEnableMonitoring(); } else if (error) { console.log(`Authentication error: ${error}`); } } // Handle logout events function handleLogoutEvent() { console.log('Logout event received'); userPubkey = null; isLoggedIn = false; currentConfig = null; // Reset relay connection state isRelayConnected = false; relayPubkey = null; // Reset UI - hide profile and show login modal hideProfileFromHeader(); showLoginModal(); updateConfigStatus(false); updateAdminSectionsVisibility(); console.log('Logout event handled successfully'); } // Update visibility of admin sections based on login and relay connection status function updateAdminSectionsVisibility() { const divConfig = document.getElementById('div_config'); const authRulesSection = document.getElementById('authRulesSection'); const databaseStatisticsSection = document.getElementById('databaseStatisticsSection'); const subscriptionDetailsSection = document.getElementById('subscriptionDetailsSection'); const nip17DMSection = document.getElementById('nip17DMSection'); const sqlQuerySection = document.getElementById('sqlQuerySection'); const shouldShow = isLoggedIn && isRelayConnected; if (divConfig) divConfig.style.display = shouldShow ? 'block' : 'none'; if (authRulesSection) authRulesSection.style.display = shouldShow ? 'block' : 'none'; if (databaseStatisticsSection) databaseStatisticsSection.style.display = shouldShow ? 'block' : 'none'; if (subscriptionDetailsSection) subscriptionDetailsSection.style.display = shouldShow ? 'block' : 'none'; if (nip17DMSection) nip17DMSection.style.display = shouldShow ? 'block' : 'none'; if (sqlQuerySection) sqlQuerySection.style.display = shouldShow ? 'block' : 'none'; // Start/stop auto-refresh based on visibility if (shouldShow && databaseStatisticsSection && databaseStatisticsSection.style.display === 'block') { // Load statistics immediately (no auto-refresh - using real-time monitoring events) sendStatsQuery().catch(error => { console.log('Auto-fetch statistics failed: ' + error.message); }); // startStatsAutoRefresh(); // DISABLED - using real-time monitoring events instead // Also load configuration and auth rules automatically when sections become visible fetchConfiguration().catch(error => { console.log('Auto-fetch configuration failed: ' + error.message); }); loadAuthRules().catch(error => { console.log('Auto-load auth rules failed: ' + error.message); }); } else { stopStatsAutoRefresh(); } // Update countdown display when visibility changes updateCountdownDisplay(); } // Show login modal function showLoginModal() { if (loginModal && loginModalContainer) { // Initialize the login UI in the modal if (window.NOSTR_LOGIN_LITE && typeof window.NOSTR_LOGIN_LITE.embed === 'function') { window.NOSTR_LOGIN_LITE.embed('#login-modal-container', { seamless: true }); } loginModal.style.display = 'flex'; } } // Hide login modal function hideLoginModal() { if (loginModal) { loginModal.style.display = 'none'; } } // Show profile in header function showProfileInHeader() { if (profileArea) { profileArea.style.display = 'flex'; } } // Hide profile from header function hideProfileFromHeader() { if (profileArea) { profileArea.style.display = 'none'; } // Also hide logout dropdown if visible if (logoutDropdown) { logoutDropdown.style.display = 'none'; } } // Update login/logout UI visibility (legacy function - kept for backward compatibility) function updateLoginLogoutUI() { // This function is now handled by showProfileInHeader() and hideProfileFromHeader() // Kept for backward compatibility with any existing code that might call it } // Show main interface after login (legacy function - kept for backward compatibility) function showMainInterface() { // This function is now handled by showProfileInHeader() and updateAdminSectionsVisibility() // Kept for backward compatibility with any existing code that might call it updateAdminSectionsVisibility(); } // Load user profile using nostr-tools pool async function loadUserProfile() { if (!userPubkey) return; console.log('Loading user profile...'); // Update header display (new system) if (headerUserName) { headerUserName.textContent = 'Loading...'; } // Update legacy elements if they exist (backward compatibility) if (persistentUserName) { persistentUserName.textContent = 'Loading...'; } if (persistentUserAbout) { persistentUserAbout.textContent = 'Loading...'; } // Convert hex pubkey to npub for initial display let displayPubkey = userPubkey; let npubLink = ''; try { if (userPubkey && userPubkey.length === 64 && /^[0-9a-fA-F]+$/.test(userPubkey)) { const npub = window.NostrTools.nip19.npubEncode(userPubkey); displayPubkey = npub; npubLink = `${npub}`; } } catch (error) { console.log('Failed to encode user pubkey to npub:', error.message); } if (persistentUserPubkey) { if (npubLink) { persistentUserPubkey.innerHTML = npubLink; } else { persistentUserPubkey.textContent = displayPubkey; } } try { // Create a SimplePool instance for profile loading const profilePool = new window.NostrTools.SimplePool(); const relays = ['wss://relay.damus.io', 'wss://relay.nostr.band', 'wss://nos.lol', 'wss://relay.primal.net', 'wss://relay.snort.social', 'wss://relay.laantungir.net']; // Get profile event (kind 0) for the user const events = await profilePool.querySync(relays, { kinds: [0], authors: [userPubkey], limit: 1 }); if (events.length > 0) { console.log('Profile event found:', events[0]); const profile = JSON.parse(events[0].content); console.log('Parsed profile:', profile); displayProfile(profile); } else { console.log('No profile events found for pubkey:', userPubkey); // Update header display (new system) if (headerUserName) { headerUserName.textContent = 'Anonymous User'; } // Update legacy elements if they exist (backward compatibility) if (persistentUserName) { persistentUserName.textContent = 'Anonymous User'; } if (persistentUserAbout) { persistentUserAbout.textContent = 'No profile found'; } // Keep the npub display } // Close the profile pool profilePool.close(relays); } catch (error) { console.log('Profile loading failed: ' + error.message); // Update header display (new system) if (headerUserName) { headerUserName.textContent = 'Error loading profile'; } // Update legacy elements if they exist (backward compatibility) if (persistentUserName) { persistentUserName.textContent = 'Error loading profile'; } if (persistentUserAbout) { persistentUserAbout.textContent = error.message; } // Keep the npub display } } // Display profile data function displayProfile(profile) { const name = profile.name || profile.display_name || profile.displayName || 'Anonymous User'; const about = profile.about || 'No description provided'; const picture = profile.picture || profile.image || null; // Convert hex pubkey to npub for display let displayPubkey = userPubkey; let npubLink = ''; try { if (userPubkey && userPubkey.length === 64 && /^[0-9a-fA-F]+$/.test(userPubkey)) { const npub = window.NostrTools.nip19.npubEncode(userPubkey); displayPubkey = npub; npubLink = `${npub}`; } } catch (error) { console.log('Failed to encode user pubkey to npub:', error.message); } // Update header profile display if (headerUserName) { headerUserName.textContent = name; } // Handle header profile picture if (headerUserImage) { if (picture && typeof picture === 'string' && (picture.startsWith('http') || picture.startsWith('https'))) { headerUserImage.src = picture; headerUserImage.style.display = 'block'; headerUserImage.onerror = function() { // Hide image on error this.style.display = 'none'; console.log('Profile image failed to load:', picture); }; } else { headerUserImage.style.display = 'none'; } } // Update legacy persistent user details (kept for backward compatibility) if (persistentUserName) persistentUserName.textContent = name; if (persistentUserPubkey && npubLink) { persistentUserPubkey.innerHTML = npubLink; } else if (persistentUserPubkey) { persistentUserPubkey.textContent = displayPubkey; } if (persistentUserAbout) persistentUserAbout.textContent = about; // Handle legacy profile picture const userImageContainer = document.getElementById('persistent-user-image'); if (userImageContainer) { if (picture && typeof picture === 'string' && picture.startsWith('http')) { // Create or update image element let img = userImageContainer.querySelector('img'); if (!img) { img = document.createElement('img'); img.className = 'user-profile-image'; img.alt = `${name}'s profile picture`; img.onerror = function() { // Hide image on error this.style.display = 'none'; }; userImageContainer.appendChild(img); } img.src = picture; img.style.display = 'block'; } else { // Hide image if no valid picture const img = userImageContainer.querySelector('img'); if (img) { img.style.display = 'none'; } } } console.log(`Profile loaded for: ${name} with pubkey: ${userPubkey}`); } // Logout function async function logout() { log('Logging out...', 'INFO'); try { // Stop auto-refresh before disconnecting stopStatsAutoRefresh(); // Clean up configuration pool if (relayPool) { log('Closing configuration pool...', 'INFO'); const url = relayConnectionUrl.value.trim(); if (url) { relayPool.close([url]); } relayPool = null; subscriptionId = null; // Reset subscription flag isSubscribed = false; } await nlLite.logout(); userPubkey = null; isLoggedIn = false; currentConfig = null; // Reset relay connection state isRelayConnected = false; relayPubkey = null; // Reset subscription flag isSubscribed = false; // Reset UI - hide profile and show login modal hideProfileFromHeader(); // showLoginModal() removed - handled by handleLogoutEvent() updateConfigStatus(false); updateAdminSectionsVisibility(); log('Logged out successfully', 'INFO'); } catch (error) { log('Logout failed: ' + error.message, 'ERROR'); } } function updateConfigStatus(loaded) { if (loaded) { configDisplay.classList.remove('hidden'); } else { configDisplay.classList.add('hidden'); } } // Generate random subscription ID (avoiding colons which are rejected by relay) function generateSubId() { // Use only alphanumeric characters, underscores, hyphens, and commas const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-,'; let result = ''; for (let i = 0; i < 12; i++) { result += chars.charAt(Math.floor(Math.random() * chars.length)); } return result; } // Configuration subscription using nostr-tools SimplePool async function subscribeToConfiguration() { try { console.log('=== STARTING SIMPLEPOOL CONFIGURATION SUBSCRIPTION ==='); // Prevent multiple simultaneous subscription attempts if (isSubscribed) { console.log('Subscription already established, skipping duplicate subscription attempt'); return true; } if (!isLoggedIn) { console.log('WARNING: Not logged in, but proceeding with subscription test'); } const url = relayConnectionUrl.value.trim(); if (!url) { console.error('Please enter a relay URL'); return false; } console.log(`Connecting to relay via SimplePool: ${url}`); // Reuse existing pool if available, otherwise create new one if (!relayPool) { console.log('Creating new SimplePool instance'); relayPool = new window.NostrTools.SimplePool(); } else { console.log('Reusing existing SimplePool instance'); } subscriptionId = generateSubId(); console.log(`Generated subscription ID: ${subscriptionId}`); console.log(`User pubkey ${userPubkey}`) // Subscribe to kind 23457 events (admin response events), kind 4 (NIP-04 DMs), kind 1059 (NIP-17 GiftWrap), and kind 34567 (monitoring events) const subscription = relayPool.subscribeMany([url], [{ since: Math.floor(Date.now() / 1000) - 5, // Look back 5 seconds to avoid race condition kinds: [23457], authors: [getRelayPubkey()], // Only listen to responses from the relay "#p": [userPubkey], // Only responses directed to this user limit: 50 }, { since: Math.floor(Date.now() / 1000), kinds: [4], // NIP-04 Direct Messages authors: [getRelayPubkey()], // Only listen to DMs from the relay "#p": [userPubkey], // Only DMs directed to this user limit: 50 }, { since: Math.floor(Date.now() / 1000), // Start from current time kinds: [1059], // NIP-17 GiftWrap events "#p": [userPubkey], // Only GiftWrap events addressed to this user limit: 50 }, { since: Math.floor(Date.now() / 1000), // Start from current time kinds: [34567], // Real-time monitoring events authors: [getRelayPubkey()], // Only listen to monitoring events from the relay "#d": isLoggedIn ? ["event_kinds", "time_stats", "top_pubkeys", "active_subscriptions", "subscription_details"] : ["event_kinds", "time_stats", "top_pubkeys", "active_subscriptions"], // Include subscription_details only when authenticated limit: 50 }], { async onevent(event) { console.log('=== EVENT RECEIVED VIA SIMPLEPOOL ==='); console.log('Event data:', event); console.log('Event kind:', event.kind); console.log('Event tags:', event.tags); console.log('Event pubkey:', event.pubkey); console.log('=== END EVENT ==='); // Handle NIP-04 DMs if (event.kind === 4) { console.log('=== NIP-04 DM RECEIVED ==='); try { // Decrypt the DM content const decryptedContent = await window.nostr.nip04.decrypt(event.pubkey, event.content); log(`Received NIP-04 DM from relay: ${decryptedContent.substring(0, 50)}...`, 'INFO'); // Add to inbox const timestamp = new Date(event.created_at * 1000).toLocaleString(); addMessageToInbox('received', decryptedContent, timestamp, event.pubkey); // Log for testing if (typeof logTestEvent === 'function') { logTestEvent('RECV', `NIP-04 DM: ${decryptedContent}`, 'DM'); } } catch (decryptError) { log(`Failed to decrypt NIP-04 DM: ${decryptError.message}`, 'ERROR'); if (typeof logTestEvent === 'function') { logTestEvent('ERROR', `Failed to decrypt DM: ${decryptError.message}`, 'DM'); } } return; } // Handle NIP-17 GiftWrap DMs if (event.kind === 1059) { console.log('=== NIP-17 GIFTWRAP RECEIVED ==='); try { // Step 1: Unwrap gift wrap to get seal const sealJson = await window.nostr.nip44.decrypt(event.pubkey, event.content); const seal = safeJsonParse(sealJson); if (!seal || seal.kind !== 13) { throw new Error('Unwrapped content is not a valid seal (kind 13)'); } // Step 2: Unseal to get rumor const rumorJson = await window.nostr.nip44.decrypt(seal.pubkey, seal.content); const rumor = safeJsonParse(rumorJson); if (!rumor || rumor.kind !== 14) { throw new Error('Unsealed content is not a valid rumor (kind 14)'); } log(`Received NIP-17 DM from relay: ${rumor.content.substring(0, 50)}...`, 'INFO'); // Add to inbox const timestamp = new Date(event.created_at * 1000).toLocaleString(); addMessageToInbox('received', rumor.content, timestamp, rumor.pubkey); // Log for testing if (typeof logTestEvent === 'function') { logTestEvent('RECV', `NIP-17 DM: ${rumor.content}`, 'DM'); } } catch (unwrapError) { log(`Failed to unwrap NIP-17 DM: ${unwrapError.message}`, 'ERROR'); if (typeof logTestEvent === 'function') { logTestEvent('ERROR', `Failed to unwrap DM: ${unwrapError.message}`, 'DM'); } } return; } // Handle admin response events (kind 23457) if (event.kind === 23457) { // Log all received messages for testing if (typeof logTestEvent === 'function') { logTestEvent('RECV', `Admin response event: ${JSON.stringify(event)}`, 'EVENT'); } // Process admin response event processAdminResponse(event); } // Handle monitoring events (kind 34567) if (event.kind === 34567) { console.log('=== MONITORING EVENT RECEIVED ==='); console.log('Monitoring event:', event); // Process monitoring event processMonitoringEvent(event); } }, oneose() { console.log('EOSE received - End of stored events'); console.log('Current config after EOSE:', currentConfig); if (!currentConfig) { console.log('No configuration events were received'); } }, onclose(reason) { console.log('Subscription closed:', reason); updateConfigStatus(false); } }); // Store subscription for cleanup relayPool.currentSubscription = subscription; // Mark as subscribed to prevent duplicate attempts isSubscribed = true; console.log('SimplePool subscription established'); return true; } catch (error) { console.error('Configuration subscription failed:', error.message); console.error('Configuration subscription failed:', error); console.error('Error stack:', error.stack); return false; } } // Process admin response events (kind 23457) async function processAdminResponse(event) { try { console.log('=== PROCESSING ADMIN RESPONSE ==='); console.log('Response event:', event); // Verify this is a kind 23457 admin response event if (event.kind !== 23457) { console.log('Ignoring non-admin response event, kind:', event.kind); return; } // Verify the event is from the relay const expectedRelayPubkey = getRelayPubkey(); if (event.pubkey !== expectedRelayPubkey) { console.log('Ignoring response from unknown pubkey:', event.pubkey); return; } // Decrypt the NIP-44 encrypted content const decryptedContent = await decryptFromRelay(event.content); if (!decryptedContent) { throw new Error('Failed to decrypt admin response content'); } console.log('Decrypted admin response:', decryptedContent); // Parse the decrypted JSON response const responseData = JSON.parse(decryptedContent); console.log('Parsed response data:', responseData); // Log the response for testing if (typeof logTestEvent === 'function') { logTestEvent('RECV', `Decrypted response: ${JSON.stringify(responseData)}`, 'RESPONSE'); } // Handle different types of admin responses handleAdminResponseData(responseData); } catch (error) { console.error('Error processing admin response:', error); if (typeof logTestEvent === 'function') { logTestEvent('ERROR', `Failed to process admin response: ${error.message}`, 'ERROR'); } } } // Initialize real-time event rate chart function initializeEventRateChart() { try { console.log('=== INITIALIZING EVENT RATE CHART ==='); const chartContainer = document.getElementById('event-rate-chart'); console.log('Chart container found:', chartContainer); if (!chartContainer) { console.log('Event rate chart container not found'); return; } // Show immediate placeholder content chartContainer.textContent = 'Initializing event rate chart...'; console.log('Set placeholder content'); // Check if ASCIIBarChart is available console.log('Checking ASCIIBarChart availability...'); console.log('typeof ASCIIBarChart:', typeof ASCIIBarChart); console.log('window.ASCIIBarChart:', window.ASCIIBarChart); if (typeof ASCIIBarChart === 'undefined') { console.log('ASCIIBarChart not available - text_graph.js may not be loaded'); // Show a more detailed error message chartContainer.innerHTML = `
⚠️ Chart library not loaded
Check: /text_graph/text_graph.js
Real-time event visualization unavailable
`; return; } // Create stub elements that the chart expects for info display createChartStubElements(); console.log('Creating ASCIIBarChart instance...'); // Initialize the chart with correct parameters based on text_graph.js API eventRateChart = new ASCIIBarChart('event-rate-chart', { maxHeight: 11, // Chart height in lines maxDataPoints: 76, // Show last 76 bins (5+ minutes of history) title: 'Events', // Chart title xAxisLabel: '', // No X-axis label yAxisLabel: '', // No Y-axis label autoFitWidth: true, // Enable responsive font sizing useBinMode: true, // Enable time bin aggregation binDuration: 4000, // 4-second time bins xAxisLabelFormat: 'elapsed' // Show elapsed time labels }); console.log('ASCIIBarChart instance created:', eventRateChart); console.log('Chart container content after init:', chartContainer.textContent); console.log('Chart container innerHTML after init:', chartContainer.innerHTML); // Force an initial render if (eventRateChart && typeof eventRateChart.render === 'function') { console.log('Forcing initial render...'); eventRateChart.render(); console.log('Chart container content after render:', chartContainer.textContent); } console.log('Event rate chart initialized successfully'); log('Real-time event rate chart initialized', 'INFO'); } catch (error) { console.error('Failed to initialize event rate chart:', error); console.error('Error stack:', error.stack); log(`Failed to initialize event rate chart: ${error.message}`, 'ERROR'); // Show detailed error message in the container const chartContainer = document.getElementById('event-rate-chart'); if (chartContainer) { chartContainer.innerHTML = `
❌ Chart initialization failed
${error.message}
Check browser console for details
`; } } } // Create stub elements that the ASCIIBarChart expects for info display function createChartStubElements() { const stubIds = ['values', 'max-value', 'scale', 'count']; stubIds.forEach(id => { if (!document.getElementById(id)) { const stubElement = document.createElement('div'); stubElement.id = id; stubElement.style.display = 'none'; // Hide stub elements document.body.appendChild(stubElement); } }); console.log('Chart stub elements created'); } // Handle monitoring events (kind 34567) async function processMonitoringEvent(event) { try { console.log('=== PROCESSING MONITORING EVENT ==='); console.log('Monitoring event:', event); // Verify this is a kind 34567 monitoring event if (event.kind !== 34567) { console.log('Ignoring non-monitoring event, kind:', event.kind); return; } // Verify the event is from the relay const expectedRelayPubkey = getRelayPubkey(); if (event.pubkey !== expectedRelayPubkey) { console.log('Ignoring monitoring event from unknown pubkey:', event.pubkey); return; } // Check the d-tag to determine which type of monitoring event this is const dTag = event.tags.find(tag => tag[0] === 'd'); if (!dTag) { console.log('Ignoring monitoring event without d-tag'); return; } // Parse the monitoring data (content is JSON, not encrypted for monitoring events) const monitoringData = JSON.parse(event.content); console.log('Parsed monitoring data:', monitoringData); // Don't add to chart here - we'll track actual event rate changes in updateStatsFromMonitoringEvent console.log('Monitoring event received - will track rate changes in stats update'); // Route to appropriate handler based on d-tag switch (dTag[1]) { case 'event_kinds': updateStatsFromMonitoringEvent(monitoringData); log('Real-time event_kinds monitoring data updated', 'INFO'); break; case 'time_stats': updateStatsFromTimeMonitoringEvent(monitoringData); log('Real-time time_stats monitoring data updated', 'INFO'); break; case 'top_pubkeys': updateStatsFromTopPubkeysMonitoringEvent(monitoringData); log('Real-time top_pubkeys monitoring data updated', 'INFO'); break; case 'active_subscriptions': updateStatsFromActiveSubscriptionsMonitoringEvent(monitoringData); log('Real-time active_subscriptions monitoring data updated', 'INFO'); break; case 'subscription_details': // Only process subscription details if user is authenticated if (isLoggedIn) { updateStatsFromSubscriptionDetailsMonitoringEvent(monitoringData); log('Real-time subscription_details monitoring data updated', 'INFO'); } else { console.log('Ignoring subscription_details monitoring event - user not authenticated'); } break; default: console.log('Ignoring monitoring event with unknown d-tag:', dTag[1]); return; } } catch (error) { console.error('Error processing monitoring event:', error); log(`Failed to process monitoring event: ${error.message}`, 'ERROR'); } } // Handle different types of admin response data function handleAdminResponseData(responseData) { try { console.log('=== HANDLING ADMIN RESPONSE DATA ==='); console.log('Response data:', responseData); console.log('Response query_type:', responseData.query_type); // Handle auth query responses - updated to match backend response types if (responseData.query_type && (responseData.query_type.includes('auth_rules') || responseData.query_type.includes('auth'))) { console.log('Routing to auth query handler'); handleAuthQueryResponse(responseData); return; } // Handle config update responses specifically if (responseData.query_type === 'config_update') { console.log('Routing to config update handler'); handleConfigUpdateResponse(responseData); return; } // Handle config query responses - updated to match backend response types if (responseData.query_type && (responseData.query_type.includes('config') || responseData.query_type.startsWith('config_'))) { console.log('Routing to config query handler'); handleConfigQueryResponse(responseData); return; } // Handle system command responses if (responseData.command) { console.log('Routing to system command handler'); handleSystemCommandResponse(responseData); return; } // Handle auth rule modification responses if (responseData.operation || responseData.rules_processed !== undefined) { console.log('Routing to auth rule modification handler'); handleAuthRuleResponse(responseData); return; } // Handle stats query responses if (responseData.query_type === 'stats_query') { console.log('Routing to stats query handler'); handleStatsQueryResponse(responseData); return; } // Handle SQL query responses if (responseData.query_type === 'sql_query') { console.log('Routing to SQL query handler'); handleSqlQueryResponse(responseData); return; } // Generic response handling console.log('Using generic response handler'); if (typeof logTestEvent === 'function') { logTestEvent('RECV', `Generic admin response: ${JSON.stringify(responseData)}`, 'RESPONSE'); } } catch (error) { console.error('Error handling admin response data:', error); if (typeof logTestEvent === 'function') { logTestEvent('ERROR', `Failed to handle response data: ${error.message}`, 'ERROR'); } } } // Handle config query responses function handleConfigQueryResponse(responseData) { console.log('=== CONFIG QUERY RESPONSE ==='); console.log('Query type:', responseData.query_type); console.log('Total results:', responseData.total_results); console.log('Data:', responseData.data); // Convert the config response data to the format expected by displayConfiguration if (responseData.data && responseData.data.length > 0) { console.log('Converting config response to display format...'); // Create a synthetic event structure for displayConfiguration const syntheticEvent = { id: 'config_response_' + Date.now(), pubkey: getRelayPubkey(), created_at: Math.floor(Date.now() / 1000), kind: 'config_response', content: 'Configuration from admin API', tags: [] }; // Convert config data to tags format responseData.data.forEach(config => { const key = config.key || config.config_key; const value = config.value || config.config_value; if (key && value !== undefined) { syntheticEvent.tags.push([key, value]); } }); console.log('Synthetic event created:', syntheticEvent); console.log('Calling displayConfiguration with synthetic event...'); // Display the configuration using the original display function displayConfiguration(syntheticEvent); // Update relay info in header with config data updateStoredRelayInfo(responseData); // Initialize toggle buttons with config data initializeToggleButtonsFromConfig(responseData); log(`Configuration loaded: ${responseData.total_results} parameters`, 'INFO'); } else { console.log('No configuration data received'); updateConfigStatus(false); } // Also log to test interface for debugging if (typeof logTestEvent === 'function') { logTestEvent('RECV', `Config query response: ${responseData.query_type}, ${responseData.total_results} results`, 'CONFIG_QUERY'); if (responseData.data && responseData.data.length > 0) { logTestEvent('RECV', '=== CONFIGURATION VALUES ===', 'CONFIG'); responseData.data.forEach((config, index) => { const key = config.key || config.config_key || `config_${index}`; const value = config.value || config.config_value || 'undefined'; const category = config.category || 'general'; const dataType = config.data_type || 'string'; logTestEvent('RECV', `${key}: ${value} (${dataType}, ${category})`, 'CONFIG'); }); logTestEvent('RECV', '=== END CONFIGURATION VALUES ===', 'CONFIG'); } else { logTestEvent('RECV', 'No configuration values found', 'CONFIG_QUERY'); } } } // Handle config update responses function handleConfigUpdateResponse(responseData) { console.log('=== CONFIG UPDATE RESPONSE ==='); console.log('Query type:', responseData.query_type); console.log('Status:', responseData.status); console.log('Data:', responseData.data); if (responseData.status === 'success') { const updatesApplied = responseData.updates_applied || 0; log(`Configuration updated successfully: ${updatesApplied} parameters changed`, 'INFO'); // Show success message with details if (responseData.data && Array.isArray(responseData.data)) { responseData.data.forEach((config, index) => { if (config.status === 'success') { log(`✓ ${config.key}: ${config.value} (${config.data_type})`, 'INFO'); } else { log(`✗ ${config.key}: ${config.error || 'Failed to update'}`, 'ERROR'); } }); } // Configuration updated successfully - user can manually refresh using Fetch Config button log('Configuration updated successfully. Click "Fetch Config" to refresh the display.', 'INFO'); } else { const errorMessage = responseData.message || responseData.error || 'Unknown error'; log(`Configuration update failed: ${errorMessage}`, 'ERROR'); // Show detailed error information if available if (responseData.data && Array.isArray(responseData.data)) { responseData.data.forEach((config, index) => { if (config.status === 'error') { log(`✗ ${config.key}: ${config.error || 'Failed to update'}`, 'ERROR'); } }); } } // Log to test interface for debugging if (typeof logTestEvent === 'function') { logTestEvent('RECV', `Config update response: ${responseData.status}`, 'CONFIG_UPDATE'); if (responseData.data && responseData.data.length > 0) { responseData.data.forEach((config, index) => { const status = config.status === 'success' ? '✓' : '✗'; const message = config.status === 'success' ? `${config.key} = ${config.value}` : `${config.key}: ${config.error || 'Failed'}`; logTestEvent('RECV', `${status} ${message}`, 'CONFIG_UPDATE'); }); } else { logTestEvent('RECV', 'No configuration update details received', 'CONFIG_UPDATE'); } } } // Handle auth query responses function handleAuthQueryResponse(responseData) { console.log('=== AUTH QUERY RESPONSE ==='); console.log('Query type:', responseData.query_type); console.log('Total results:', responseData.total_results); console.log('Data:', responseData.data); // Update the current auth rules with the response data if (responseData.data && Array.isArray(responseData.data)) { currentAuthRules = responseData.data; console.log('Updated currentAuthRules with', currentAuthRules.length, 'rules'); // Always show the auth rules table when we receive data (no VIEW RULES button anymore) console.log('Auto-showing auth rules table since we received data...'); showAuthRulesTable(); updateAuthRulesStatus('loaded'); log(`Loaded ${responseData.total_results} auth rules from relay`, 'INFO'); } else { currentAuthRules = []; console.log('No auth rules data received, cleared currentAuthRules'); // Show empty table (no VIEW RULES button anymore) console.log('Auto-showing auth rules table with empty data...'); showAuthRulesTable(); updateAuthRulesStatus('loaded'); log('No auth rules found on relay', 'INFO'); } if (typeof logTestEvent === 'function') { logTestEvent('RECV', `Auth query response: ${responseData.query_type}, ${responseData.total_results} results`, 'AUTH_QUERY'); if (responseData.data && responseData.data.length > 0) { responseData.data.forEach((rule, index) => { logTestEvent('RECV', `Rule ${index + 1}: ${rule.rule_type} - ${rule.pattern_value || rule.rule_target}`, 'AUTH_RULE'); }); } else { logTestEvent('RECV', 'No auth rules found', 'AUTH_QUERY'); } } } // Handle system command responses function handleSystemCommandResponse(responseData) { console.log('=== SYSTEM COMMAND RESPONSE ==='); console.log('Command:', responseData.command); console.log('Status:', responseData.status); // Handle delete auth rule responses if (responseData.command === 'delete_auth_rule') { if (responseData.status === 'success') { log('Auth rule deleted successfully', 'INFO'); // Refresh the auth rules display loadAuthRules(); } else { log(`Failed to delete auth rule: ${responseData.message || 'Unknown error'}`, 'ERROR'); } } // Handle clear all auth rules responses if (responseData.command === 'clear_all_auth_rules') { if (responseData.status === 'success') { const rulesCleared = responseData.rules_cleared || 0; log(`Successfully cleared ${rulesCleared} auth rules`, 'INFO'); // Clear local auth rules and refresh display currentAuthRules = []; displayAuthRules(currentAuthRules); } else { log(`Failed to clear auth rules: ${responseData.message || 'Unknown error'}`, 'ERROR'); } } if (typeof logTestEvent === 'function') { logTestEvent('RECV', `System command response: ${responseData.command} - ${responseData.status}`, 'SYSTEM_CMD'); } } // Handle auth rule modification responses function handleAuthRuleResponse(responseData) { console.log('=== AUTH RULE MODIFICATION RESPONSE ==='); console.log('Operation:', responseData.operation); console.log('Status:', responseData.status); // Handle auth rule addition/modification responses if (responseData.status === 'success') { const rulesProcessed = responseData.rules_processed || 0; log(`Successfully processed ${rulesProcessed} auth rule modifications`, 'INFO'); // Refresh the auth rules display to show the new rules if (authRulesTableContainer && authRulesTableContainer.style.display !== 'none') { loadAuthRules(); } } else { log(`Failed to process auth rule modifications: ${responseData.message || 'Unknown error'}`, 'ERROR'); } if (typeof logTestEvent === 'function') { logTestEvent('RECV', `Auth rule response: ${responseData.operation} - ${responseData.status}`, 'AUTH_RULE'); if (responseData.processed_rules) { responseData.processed_rules.forEach((rule, index) => { logTestEvent('RECV', `Processed rule ${index + 1}: ${rule.rule_type} - ${rule.pattern_value || rule.rule_target}`, 'AUTH_RULE'); }); } } } // Helper function to decrypt content from relay using NIP-44 async function decryptFromRelay(encryptedContent) { try { console.log('Decrypting content from relay...'); // Get the relay public key for decryption const relayPubkey = getRelayPubkey(); // Use NIP-07 extension's NIP-44 decrypt method if (!window.nostr || !window.nostr.nip44) { throw new Error('NIP-44 decryption not available via NIP-07 extension'); } const decryptedContent = await window.nostr.nip44.decrypt(relayPubkey, encryptedContent); if (!decryptedContent) { throw new Error('NIP-44 decryption returned empty result'); } console.log('Successfully decrypted content from relay'); return decryptedContent; } catch (error) { console.error('NIP-44 decryption failed:', error); throw error; } } // Fetch configuration using admin API async function fetchConfiguration() { try { console.log('=== FETCHING CONFIGURATION VIA ADMIN API ==='); // Require both login and relay connection if (!isLoggedIn || !userPubkey) { throw new Error('Must be logged in to fetch configuration'); } if (!isRelayConnected || !relayPubkey) { throw new Error('Must be connected to relay to fetch configuration. Please use the Relay Connection section first.'); } // First establish subscription to receive responses (only if not already subscribed) const subscriptionResult = await subscribeToConfiguration(); if (!subscriptionResult) { throw new Error('Failed to establish admin response subscription'); } // Wait a moment for subscription to be established (only if we just created it) if (!isSubscribed) { await new Promise(resolve => setTimeout(resolve, 500)); } // Send config query command if logged in if (isLoggedIn && userPubkey && relayPool) { console.log('Sending config query command...'); // Create command array for getting configuration const command_array = ["config_query", "all"]; // Encrypt the command array directly using NIP-44 const encrypted_content = await encryptForRelay(JSON.stringify(command_array)); if (!encrypted_content) { throw new Error('Failed to encrypt command array'); } // Create single kind 23456 admin event const configEvent = { kind: 23456, pubkey: userPubkey, created_at: Math.floor(Date.now() / 1000), tags: [["p", getRelayPubkey()]], content: encrypted_content }; // Sign the event const signedEvent = await window.nostr.signEvent(configEvent); if (!signedEvent || !signedEvent.sig) { throw new Error('Event signing failed'); } console.log('Config query event signed, publishing...'); // Publish via SimplePool with detailed error diagnostics const url = relayConnectionUrl.value.trim(); const publishPromises = relayPool.publish([url], signedEvent); // Use Promise.allSettled to capture per-relay outcomes instead of Promise.any const results = await Promise.allSettled(publishPromises); // Log detailed publish results for diagnostics let successCount = 0; results.forEach((result, index) => { if (result.status === 'fulfilled') { successCount++; console.log(`✅ Relay ${index} (${url}): Event published successfully`); if (typeof logTestEvent === 'function') { logTestEvent('INFO', `Relay ${index} publish success`, 'PUBLISH'); } } else { console.error(`❌ Relay ${index} (${url}): Publish failed:`, result.reason); if (typeof logTestEvent === 'function') { logTestEvent('ERROR', `Relay ${index} publish failed: ${result.reason?.message || result.reason}`, 'PUBLISH'); } } }); // Throw error if all relays failed if (successCount === 0) { const errorDetails = results.map((r, i) => `Relay ${i}: ${r.reason?.message || r.reason}`).join('; '); throw new Error(`All relays rejected the event. Details: ${errorDetails}`); } console.log('Config query command sent successfully - waiting for response...'); } else { console.log('Not logged in - only subscription established for testing'); } return true; } catch (error) { console.error('Failed to fetch configuration:', error); return false; } } function displayConfiguration(event) { try { console.log('=== DISPLAYING CONFIGURATION EVENT ==='); console.log('Event received for display:', event); currentConfig = event; // Clear existing table configTableBody.innerHTML = ''; // Display tags (editable configuration parameters only) console.log(`Processing ${event.tags.length} configuration parameters`); event.tags.forEach((tag, index) => { if (tag.length >= 2) { const row = document.createElement('tr'); const key = tag[0]; const value = tag[1]; // Create editable input for value const valueInput = document.createElement('input'); valueInput.type = 'text'; valueInput.value = value; valueInput.className = 'config-value-input'; valueInput.dataset.key = key; valueInput.dataset.originalValue = value; valueInput.dataset.rowIndex = index; // Create clickable Actions cell const actionsCell = document.createElement('td'); actionsCell.className = 'config-actions-cell'; actionsCell.textContent = 'SAVE'; actionsCell.dataset.key = key; actionsCell.dataset.originalValue = value; actionsCell.dataset.rowIndex = index; // Initially hide the SAVE text actionsCell.style.color = 'transparent'; // Show SAVE text and make clickable when value changes valueInput.addEventListener('input', function () { if (this.value !== this.dataset.originalValue) { actionsCell.style.color = 'var(--primary-color)'; actionsCell.style.cursor = 'pointer'; actionsCell.onclick = () => saveIndividualConfig(key, valueInput.value, valueInput.dataset.originalValue, actionsCell); } else { actionsCell.style.color = 'transparent'; actionsCell.style.cursor = 'default'; actionsCell.onclick = null; } }); row.innerHTML = `${key}`; row.cells[1].appendChild(valueInput); row.appendChild(actionsCell); configTableBody.appendChild(row); } }); // Show message if no configuration parameters found if (event.tags.length === 0) { const row = document.createElement('tr'); row.innerHTML = `No configuration parameters found`; configTableBody.appendChild(row); } console.log('Configuration display completed successfully'); updateConfigStatus(true); } catch (error) { console.error('Error in displayConfiguration:', error.message); console.error('Display configuration error:', error); } } // Save individual configuration parameter async function saveIndividualConfig(key, newValue, originalValue, actionsCell) { if (!isLoggedIn || !userPubkey) { log('Must be logged in to save configuration', 'ERROR'); return; } if (!currentConfig) { log('No current configuration to update', 'ERROR'); return; } // Don't save if value hasn't changed if (newValue === originalValue) { return; } try { log(`Saving individual config: ${key} = ${newValue}`, 'INFO'); // Determine data type based on key name let dataType = 'string'; if (['max_connections', 'pow_min_difficulty', 'nip42_challenge_timeout', 'max_subscriptions_per_client', 'max_event_tags', 'max_content_length'].includes(key)) { dataType = 'integer'; } else if (['auth_enabled', 'nip42_auth_required', 'nip40_expiration_enabled'].includes(key)) { dataType = 'boolean'; } // Determine category based on key name let category = 'general'; if (key.startsWith('relay_')) { category = 'relay'; } else if (key.startsWith('nip40_')) { category = 'expiration'; } else if (key.startsWith('nip42_') || key.startsWith('auth_')) { category = 'authentication'; } else if (key.startsWith('pow_')) { category = 'proof_of_work'; } else if (key.startsWith('max_')) { category = 'limits'; } const configObj = { key: key, value: newValue, data_type: dataType, category: category }; // Update cell during save actionsCell.textContent = 'SAVING...'; actionsCell.style.color = 'var(--accent-color)'; actionsCell.style.cursor = 'not-allowed'; actionsCell.onclick = null; // Send single config update await sendConfigUpdateCommand([configObj]); // Update the original value on success const input = actionsCell.parentElement.cells[1].querySelector('input'); if (input) { input.dataset.originalValue = newValue; // Hide SAVE text since value now matches original actionsCell.style.color = 'transparent'; actionsCell.style.cursor = 'default'; actionsCell.onclick = null; } actionsCell.textContent = 'SAVED'; actionsCell.style.color = 'var(--accent-color)'; setTimeout(() => { actionsCell.textContent = 'SAVE'; // Keep transparent if value matches original if (input && input.value === input.dataset.originalValue) { actionsCell.style.color = 'transparent'; } }, 2000); log(`Successfully saved config: ${key} = ${newValue}`, 'INFO'); } catch (error) { log(`Failed to save individual config ${key}: ${error.message}`, 'ERROR'); actionsCell.textContent = 'SAVE'; actionsCell.style.color = 'var(--primary-color)'; actionsCell.style.cursor = 'pointer'; actionsCell.onclick = () => saveIndividualConfig(key, actionsCell.parentElement.cells[1].querySelector('input').value, originalValue, actionsCell); } } // Send config update command using kind 23456 with Administrator API (inner events) async function sendConfigUpdateCommand(configObjects) { try { if (!relayPool) { throw new Error('SimplePool connection not available'); } console.log(`Sending config_update command with ${configObjects.length} configuration object(s)`); // Create command array for config update const command_array = ["config_update", configObjects]; // Encrypt the command array directly using NIP-44 const encrypted_content = await encryptForRelay(JSON.stringify(command_array)); if (!encrypted_content) { throw new Error('Failed to encrypt command array'); } // Create single kind 23456 admin event const configEvent = { kind: 23456, pubkey: userPubkey, created_at: Math.floor(Date.now() / 1000), tags: [["p", getRelayPubkey()]], content: encrypted_content }; // Sign the event const signedEvent = await window.nostr.signEvent(configEvent); if (!signedEvent || !signedEvent.sig) { throw new Error('Event signing failed'); } console.log(`Config update event signed with ${configObjects.length} object(s)`); // Publish via SimplePool with detailed error diagnostics const url = relayConnectionUrl.value.trim(); const publishPromises = relayPool.publish([url], signedEvent); // Use Promise.allSettled to capture per-relay outcomes instead of Promise.any const results = await Promise.allSettled(publishPromises); // Log detailed publish results for diagnostics let successCount = 0; results.forEach((result, index) => { if (result.status === 'fulfilled') { successCount++; console.log(`✅ Config Update Relay ${index} (${url}): Event published successfully`); if (typeof logTestEvent === 'function') { logTestEvent('INFO', `Config update relay ${index} publish success`, 'PUBLISH'); } } else { console.error(`❌ Config Update Relay ${index} (${url}): Publish failed:`, result.reason); if (typeof logTestEvent === 'function') { logTestEvent('ERROR', `Config update relay ${index} publish failed: ${result.reason?.message || result.reason}`, 'PUBLISH'); } } }); // Throw error if all relays failed if (successCount === 0) { const errorDetails = results.map((r, i) => `Relay ${i}: ${r.reason?.message || r.reason}`).join('; '); throw new Error(`All relays rejected config update event. Details: ${errorDetails}`); } console.log(`Config update command sent successfully with ${configObjects.length} configuration object(s)`); // Log for testing if (typeof logTestEvent === 'function') { logTestEvent('SENT', `Config update command: ${configObjects.length} object(s)`, 'CONFIG_UPDATE'); configObjects.forEach((config, index) => { logTestEvent('SENT', `Config ${index + 1}: ${config.key} = ${config.value} (${config.data_type})`, 'CONFIG'); }); } } catch (error) { console.error(`Failed to send config_update command:`, error); throw error; } } // Profile area click handler for logout dropdown function toggleLogoutDropdown(event) { if (!logoutDropdown) return; // Only toggle if clicking on the image, not the text or container if (event.target === headerUserImage) { const isVisible = logoutDropdown.style.display === 'block'; logoutDropdown.style.display = isVisible ? 'none' : 'block'; } } // Close logout dropdown when clicking outside document.addEventListener('click', function(event) { if (profileArea && logoutDropdown && !profileArea.contains(event.target)) { logoutDropdown.style.display = 'none'; } }); // Initialize profile area click handler if (profileArea) { profileArea.addEventListener('click', toggleLogoutDropdown); } // Initialize logout button handler if (logoutBtn) { logoutBtn.addEventListener('click', function(e) { e.stopPropagation(); // Prevent profile area click logout(); }); } // Initialize dark mode button handler const darkModeBtn = document.getElementById('dark-mode-btn'); if (darkModeBtn) { darkModeBtn.addEventListener('click', function(e) { e.stopPropagation(); // Prevent profile area click toggleDarkMode(); }); } // Initialize relay pubkey container click handler for clipboard copy const relayPubkeyContainer = document.getElementById('relay-pubkey-container'); if (relayPubkeyContainer) { relayPubkeyContainer.addEventListener('click', async function() { const relayPubkeyElement = document.getElementById('relay-pubkey'); if (relayPubkeyElement && relayPubkeyElement.textContent !== 'Loading...') { try { // Get the full npub (remove line breaks for clipboard) const fullNpub = relayPubkeyElement.textContent.replace(/\n/g, ''); await navigator.clipboard.writeText(fullNpub); // Add copied class for visual feedback relayPubkeyContainer.classList.add('copied'); // Remove the class after animation completes setTimeout(() => { relayPubkeyContainer.classList.remove('copied'); }, 500); log('Relay npub copied to clipboard', 'INFO'); } catch (error) { log('Failed to copy relay npub to clipboard', 'ERROR'); } } }); } // Event handlers fetchConfigBtn.addEventListener('click', function (e) { e.preventDefault(); e.stopPropagation(); fetchConfiguration().catch(error => { console.log('Manual fetch configuration failed: ' + error.message); }); }); // ================================ // AUTH RULES MANAGEMENT FUNCTIONS // ================================ // Global auth rules state let currentAuthRules = []; let editingAuthRule = null; // DOM elements for auth rules const authRulesSection = document.getElementById('authRulesSection'); const refreshAuthRulesBtn = document.getElementById('refreshAuthRulesBtn'); const authRulesTableContainer = document.getElementById('authRulesTableContainer'); const authRulesTableBody = document.getElementById('authRulesTableBody'); const authRuleFormContainer = document.getElementById('authRuleFormContainer'); const authRuleForm = document.getElementById('authRuleForm'); const authRuleFormTitle = document.getElementById('authRuleFormTitle'); const saveAuthRuleBtn = document.getElementById('saveAuthRuleBtn'); const cancelAuthRuleBtn = document.getElementById('cancelAuthRuleBtn'); // Show auth rules section after login function showAuthRulesSection() { if (authRulesSection) { authRulesSection.style.display = 'block'; updateAuthRulesStatus('ready'); log('Auth rules section is now available', 'INFO'); } } // Hide auth rules section on logout function hideAuthRulesSection() { if (authRulesSection) { authRulesSection.style.display = 'none'; // Add null checks for all elements if (authRulesTableContainer) { authRulesTableContainer.style.display = 'none'; } if (authRuleFormContainer) { authRuleFormContainer.style.display = 'none'; } currentAuthRules = []; editingAuthRule = null; log('Auth rules section hidden', 'INFO'); } } // Update auth rules status indicator (removed - no status element) function updateAuthRulesStatus(status) { // Status element removed - no-op } // Load auth rules from relay using admin API async function loadAuthRules() { try { log('Loading auth rules via admin API...', 'INFO'); updateAuthRulesStatus('loading'); if (!isLoggedIn || !userPubkey) { throw new Error('Must be logged in to load auth rules'); } if (!relayPool) { throw new Error('SimplePool connection not available'); } // Create command array for getting all auth rules const command_array = ["auth_query", "all"]; // Encrypt the command array directly using NIP-44 const encrypted_content = await encryptForRelay(JSON.stringify(command_array)); if (!encrypted_content) { throw new Error('Failed to encrypt command array'); } // Create single kind 23456 admin event const authEvent = { kind: 23456, pubkey: userPubkey, created_at: Math.floor(Date.now() / 1000), tags: [["p", getRelayPubkey()]], content: encrypted_content }; // Sign the event const signedEvent = await window.nostr.signEvent(authEvent); if (!signedEvent || !signedEvent.sig) { throw new Error('Event signing failed'); } log('Sending auth rules query to relay...', 'INFO'); // Publish via SimplePool with detailed error diagnostics const url = relayConnectionUrl.value.trim(); const publishPromises = relayPool.publish([url], signedEvent); // Use Promise.allSettled to capture per-relay outcomes instead of Promise.any const results = await Promise.allSettled(publishPromises); // Log detailed publish results for diagnostics let successCount = 0; results.forEach((result, index) => { if (result.status === 'fulfilled') { successCount++; console.log(`✅ Auth Rules Query Relay ${index} (${url}): Event published successfully`); if (typeof logTestEvent === 'function') { logTestEvent('INFO', `Auth rules query relay ${index} publish success`, 'PUBLISH'); } } else { console.error(`❌ Auth Rules Query Relay ${index} (${url}): Publish failed:`, result.reason); if (typeof logTestEvent === 'function') { logTestEvent('ERROR', `Auth rules query relay ${index} publish failed: ${result.reason?.message || result.reason}`, 'PUBLISH'); } } }); // Throw error if all relays failed if (successCount === 0) { const errorDetails = results.map((r, i) => `Relay ${i}: ${r.reason?.message || r.reason}`).join('; '); throw new Error(`All relays rejected auth rules query event. Details: ${errorDetails}`); } log('Auth rules query sent successfully - waiting for response...', 'INFO'); updateAuthRulesStatus('loaded'); } catch (error) { log(`Failed to load auth rules: ${error.message}`, 'ERROR'); updateAuthRulesStatus('error'); currentAuthRules = []; displayAuthRules(currentAuthRules); } } // Display auth rules in the table function displayAuthRules(rules) { console.log('=== DISPLAY AUTH RULES DEBUG ==='); console.log('authRulesTableBody element:', authRulesTableBody); console.log('Rules to display:', rules); console.log('Rules length:', rules ? rules.length : 'undefined'); console.log('authRulesTableContainer display:', authRulesTableContainer ? authRulesTableContainer.style.display : 'element not found'); if (!authRulesTableBody) { console.log('ERROR: authRulesTableBody element not found'); return; } authRulesTableBody.innerHTML = ''; console.log('Cleared existing table content'); if (!rules || rules.length === 0) { console.log('No rules to display, showing empty message'); const row = document.createElement('tr'); row.innerHTML = `No auth rules configured`; authRulesTableBody.appendChild(row); console.log('Added empty rules message row'); return; } console.log(`Displaying ${rules.length} auth rules`); rules.forEach((rule, index) => { console.log(`Adding rule ${index + 1}:`, rule); const row = document.createElement('tr'); // Convert hex pubkey to npub for display in pattern_value let displayPatternValue = rule.pattern_value || rule.rule_target || '-'; let patternValueLink = displayPatternValue; try { if (rule.pattern_value && rule.pattern_value.length === 64 && /^[0-9a-fA-F]+$/.test(rule.pattern_value)) { const npub = window.NostrTools.nip19.npubEncode(rule.pattern_value); displayPatternValue = npub; patternValueLink = `${npub}`; } } catch (error) { console.log('Failed to encode pattern_value to npub:', error.message); } row.innerHTML = ` ${rule.rule_type} ${rule.pattern_type || rule.operation || '-'} ${patternValueLink} ${rule.enabled !== false ? 'Active' : 'Inactive'}
`; authRulesTableBody.appendChild(row); }); // Update status display console.log(`Total Rules: ${rules.length}, Active Rules: ${rules.filter(r => r.enabled !== false).length}`); console.log('=== END DISPLAY AUTH RULES DEBUG ==='); } // Show auth rules table (automatically called when auth rules are loaded) function showAuthRulesTable() { console.log('=== SHOW AUTH RULES TABLE DEBUG ==='); console.log('authRulesTableContainer element:', authRulesTableContainer); console.log('Current display style:', authRulesTableContainer ? authRulesTableContainer.style.display : 'element not found'); if (authRulesTableContainer) { authRulesTableContainer.style.display = 'block'; console.log('Set authRulesTableContainer display to block'); // If we already have cached auth rules, display them immediately if (currentAuthRules && currentAuthRules.length >= 0) { console.log('Displaying cached auth rules:', currentAuthRules.length, 'rules'); displayAuthRules(currentAuthRules); updateAuthRulesStatus('loaded'); log(`Auth rules table displayed with ${currentAuthRules.length} cached rules`, 'INFO'); } else { // No cached rules, load from relay console.log('No cached auth rules, loading from relay...'); loadAuthRules(); log('Auth rules table displayed - loading from relay', 'INFO'); } } else { console.log('ERROR: authRulesTableContainer element not found'); } console.log('=== END SHOW AUTH RULES TABLE DEBUG ==='); } // Show add auth rule form function showAddAuthRuleForm() { if (authRuleFormContainer && authRuleFormTitle) { editingAuthRule = null; authRuleFormTitle.textContent = 'Add Auth Rule'; authRuleForm.reset(); authRuleFormContainer.style.display = 'block'; log('Opened add auth rule form', 'INFO'); } } // Show edit auth rule form function editAuthRule(index) { if (index < 0 || index >= currentAuthRules.length) return; const rule = currentAuthRules[index]; editingAuthRule = { ...rule, index: index }; if (authRuleFormTitle && authRuleForm) { authRuleFormTitle.textContent = 'Edit Auth Rule'; // Populate form fields document.getElementById('authRuleType').value = rule.rule_type || ''; document.getElementById('authPatternType').value = rule.pattern_type || rule.operation || ''; document.getElementById('authPatternValue').value = rule.pattern_value || rule.rule_target || ''; document.getElementById('authRuleAction').value = rule.action || 'allow'; document.getElementById('authRuleDescription').value = rule.description || ''; authRuleFormContainer.style.display = 'block'; log(`Editing auth rule: ${rule.rule_type} - ${rule.pattern_value || rule.rule_target}`, 'INFO'); } } // Delete auth rule using Administrator API (inner events) async function deleteAuthRule(index) { if (index < 0 || index >= currentAuthRules.length) return; const rule = currentAuthRules[index]; const confirmMsg = `Delete auth rule: ${rule.rule_type} - ${rule.pattern_value || rule.rule_target}?`; if (!confirm(confirmMsg)) return; try { log(`Deleting auth rule: ${rule.rule_type} - ${rule.pattern_value || rule.rule_target}`, 'INFO'); if (!isLoggedIn || !userPubkey) { throw new Error('Must be logged in to delete auth rules'); } if (!relayPool) { throw new Error('SimplePool connection not available'); } // Create command array for deleting auth rule // Format: ["system_command", "delete_auth_rule", rule_type, pattern_type, pattern_value] const rule_type = rule.rule_type; const pattern_type = rule.pattern_type || 'pubkey'; const pattern_value = rule.pattern_value || rule.rule_target; const command_array = ["system_command", "delete_auth_rule", rule_type, pattern_type, pattern_value]; // Encrypt the command array directly using NIP-44 const encrypted_content = await encryptForRelay(JSON.stringify(command_array)); if (!encrypted_content) { throw new Error('Failed to encrypt command array'); } // Create single kind 23456 admin event const authEvent = { kind: 23456, pubkey: userPubkey, created_at: Math.floor(Date.now() / 1000), tags: [["p", getRelayPubkey()]], content: encrypted_content }; // Sign the event const signedEvent = await window.nostr.signEvent(authEvent); if (!signedEvent || !signedEvent.sig) { throw new Error('Event signing failed'); } log('Sending delete auth rule command to relay...', 'INFO'); // Publish via SimplePool with detailed error diagnostics const url = relayConnectionUrl.value.trim(); const publishPromises = relayPool.publish([url], signedEvent); // Use Promise.allSettled to capture per-relay outcomes instead of Promise.any const results = await Promise.allSettled(publishPromises); // Log detailed publish results for diagnostics let successCount = 0; results.forEach((result, index) => { if (result.status === 'fulfilled') { successCount++; console.log(`✅ Delete Auth Rule Relay ${index} (${url}): Event published successfully`); if (typeof logTestEvent === 'function') { logTestEvent('INFO', `Delete auth rule relay ${index} publish success`, 'PUBLISH'); } } else { console.error(`❌ Delete Auth Rule Relay ${index} (${url}): Publish failed:`, result.reason); if (typeof logTestEvent === 'function') { logTestEvent('ERROR', `Delete auth rule relay ${index} publish failed: ${result.reason?.message || result.reason}`, 'PUBLISH'); } } }); // Throw error if all relays failed if (successCount === 0) { const errorDetails = results.map((r, i) => `Relay ${i}: ${r.reason?.message || r.reason}`).join('; '); throw new Error(`All relays rejected delete auth rule event. Details: ${errorDetails}`); } log('Delete auth rule command sent successfully - waiting for response...', 'INFO'); // Remove from local array immediately for UI responsiveness currentAuthRules.splice(index, 1); displayAuthRules(currentAuthRules); } catch (error) { log(`Failed to delete auth rule: ${error.message}`, 'ERROR'); } } // Hide auth rule form function hideAuthRuleForm() { if (authRuleFormContainer) { authRuleFormContainer.style.display = 'none'; editingAuthRule = null; log('Auth rule form hidden', 'INFO'); } } // Validate auth rule form function validateAuthRuleForm() { const ruleType = document.getElementById('authRuleType').value; const patternType = document.getElementById('authPatternType').value; const patternValue = document.getElementById('authPatternValue').value.trim(); const action = document.getElementById('authRuleAction').value; if (!ruleType) { alert('Please select a rule type'); return false; } if (!patternType) { alert('Please select a pattern type'); return false; } if (!patternValue) { alert('Please enter a pattern value'); return false; } if (!action) { alert('Please select an action'); return false; } // Validate pubkey format for pubkey rules if ((ruleType === 'pubkey_whitelist' || ruleType === 'pubkey_blacklist') && patternValue.length !== 64) { alert('Pubkey must be exactly 64 hex characters'); return false; } // Validate hex format for pubkey rules if ((ruleType === 'pubkey_whitelist' || ruleType === 'pubkey_blacklist')) { const hexPattern = /^[0-9a-fA-F]+$/; if (!hexPattern.test(patternValue)) { alert('Pubkey must contain only hex characters (0-9, a-f, A-F)'); return false; } } return true; } // Save auth rule (add or update) async function saveAuthRule(event) { event.preventDefault(); if (!validateAuthRuleForm()) return; try { const ruleData = { rule_type: document.getElementById('authRuleType').value, pattern_type: document.getElementById('authPatternType').value, pattern_value: document.getElementById('authPatternValue').value.trim(), action: document.getElementById('authRuleAction').value, description: document.getElementById('authRuleDescription').value.trim() || null, enabled: true }; if (editingAuthRule) { log(`Updating auth rule: ${ruleData.rule_type} - ${ruleData.pattern_value}`, 'INFO'); // TODO: Implement actual rule update via WebSocket kind 23456 event // For now, just update local array currentAuthRules[editingAuthRule.index] = { ...ruleData, id: editingAuthRule.id || Date.now() }; log('Auth rule updated (placeholder implementation)', 'INFO'); } else { log(`Adding new auth rule: ${ruleData.rule_type} - ${ruleData.pattern_value}`, 'INFO'); // TODO: Implement actual rule creation via WebSocket kind 23456 event // For now, just add to local array currentAuthRules.push({ ...ruleData, id: Date.now() }); log('Auth rule added (placeholder implementation)', 'INFO'); } displayAuthRules(currentAuthRules); hideAuthRuleForm(); } catch (error) { log(`Failed to save auth rule: ${error.message}`, 'ERROR'); } } // Auto-enable monitoring when admin logs in async function autoEnableMonitoring() { if (!isLoggedIn || !relayPool) { log('Cannot auto-enable monitoring: not logged in or no relay connection', 'WARNING'); return; } try { log('Auto-enabling monitoring for admin session...', 'INFO'); // Send enable_monitoring command const commandArray = ["enable_monitoring"]; const requestEvent = await sendAdminCommand(commandArray); if (requestEvent) { log('Monitoring auto-enabled for admin session', 'INFO'); } else { log('Failed to auto-enable monitoring', 'ERROR'); } } catch (error) { log(`Failed to auto-enable monitoring: ${error.message}`, 'ERROR'); } } // Update existing logout and showMainInterface functions to handle auth rules and NIP-17 DMs const originalLogout = logout; logout = async function () { hideAuthRulesSection(); // Clear DM inbox and outbox on logout if (dmInbox) { dmInbox.innerHTML = '
No messages received yet.
'; } if (dmOutbox) { dmOutbox.value = ''; } await originalLogout(); }; const originalShowMainInterface = showMainInterface; showMainInterface = function () { originalShowMainInterface(); // Removed showAuthRulesSection() call - visibility now handled by updateAdminSectionsVisibility() }; // Auth rules event handlers if (refreshAuthRulesBtn) { refreshAuthRulesBtn.addEventListener('click', function (e) { e.preventDefault(); loadAuthRules(); }); } if (authRuleForm) { authRuleForm.addEventListener('submit', saveAuthRule); } if (cancelAuthRuleBtn) { cancelAuthRuleBtn.addEventListener('click', function (e) { e.preventDefault(); hideAuthRuleForm(); }); } // ================================ // STREAMLINED AUTH RULE FUNCTIONS // ================================ // Utility function to convert nsec to hex pubkey or npub to hex pubkey function nsecToHex(input) { if (!input || input.trim().length === 0) { return null; } const trimmed = input.trim(); // If it's already 64-char hex, return as-is if (/^[0-9a-fA-F]{64}$/.test(trimmed)) { return trimmed; } // If it starts with nsec1, try to decode if (trimmed.startsWith('nsec1')) { try { if (window.NostrTools && window.NostrTools.nip19 && window.NostrTools.nip19.decode) { const decoded = window.NostrTools.nip19.decode(trimmed); if (decoded.type === 'nsec') { // Handle different versions of nostr-tools if (typeof decoded.data === 'string') { // v1 style - data is already hex return decoded.data; } else { // v2 style - data is Uint8Array const hexPubkey = Array.from(decoded.data) .map(b => b.toString(16).padStart(2, '0')) .join(''); return hexPubkey; } } } } catch (error) { console.error('Failed to decode nsec:', error); return null; } } // If it starts with npub1, try to decode to hex if (trimmed.startsWith('npub1')) { try { if (window.NostrTools && window.NostrTools.nip19 && window.NostrTools.nip19.decode) { const decoded = window.NostrTools.nip19.decode(trimmed); if (decoded.type === 'npub') { // Handle different versions of nostr-tools if (typeof decoded.data === 'string') { // v1 style - data is already hex return decoded.data; } else { // v2 style - data is Uint8Array const hexPubkey = Array.from(decoded.data) .map(b => b.toString(16).padStart(2, '0')) .join(''); return hexPubkey; } } } } catch (error) { console.error('Failed to decode npub:', error); return null; } } return null; // Invalid format } // Add blacklist rule (updated to use combined input) function addBlacklistRule() { const input = document.getElementById('authRulePubkey'); if (!input) return; const inputValue = input.value.trim(); if (!inputValue) { log('Please enter a pubkey or nsec', 'ERROR'); return; } // Convert nsec or npub to hex if needed const hexPubkey = nsecToHex(inputValue); if (!hexPubkey) { log('Invalid pubkey format. Please enter nsec1..., npub1..., or 64-character hex', 'ERROR'); return; } // Validate hex length if (hexPubkey.length !== 64) { log('Invalid pubkey length. Must be exactly 64 characters', 'ERROR'); return; } log('Adding to blacklist...', 'INFO'); // Create auth rule data const ruleData = { rule_type: 'pubkey_blacklist', pattern_type: 'Global', pattern_value: hexPubkey, action: 'deny' }; // Add to WebSocket queue for processing addAuthRuleViaWebSocket(ruleData) .then(() => { log(`Pubkey ${hexPubkey.substring(0, 16)}... added to blacklist`, 'INFO'); input.value = ''; // Refresh auth rules display if visible if (authRulesTableContainer && authRulesTableContainer.style.display !== 'none') { loadAuthRules(); } }) .catch(error => { log(`Failed to add rule: ${error.message}`, 'ERROR'); }); } // Add whitelist rule (updated to use combined input) function addWhitelistRule() { const input = document.getElementById('authRulePubkey'); const warningDiv = document.getElementById('whitelistWarning'); if (!input) return; const inputValue = input.value.trim(); if (!inputValue) { log('Please enter a pubkey or nsec', 'ERROR'); return; } // Convert nsec or npub to hex if needed const hexPubkey = nsecToHex(inputValue); if (!hexPubkey) { log('Invalid pubkey format. Please enter nsec1..., npub1..., or 64-character hex', 'ERROR'); return; } // Validate hex length if (hexPubkey.length !== 64) { log('Invalid pubkey length. Must be exactly 64 characters', 'ERROR'); return; } // Show whitelist warning if (warningDiv) { warningDiv.style.display = 'block'; } log('Adding to whitelist...', 'INFO'); // Create auth rule data const ruleData = { rule_type: 'pubkey_whitelist', pattern_type: 'Global', pattern_value: hexPubkey, action: 'allow' }; // Add to WebSocket queue for processing addAuthRuleViaWebSocket(ruleData) .then(() => { log(`Pubkey ${hexPubkey.substring(0, 16)}... added to whitelist`, 'INFO'); input.value = ''; // Refresh auth rules display if visible if (authRulesTableContainer && authRulesTableContainer.style.display !== 'none') { loadAuthRules(); } }) .catch(error => { log(`Failed to add rule: ${error.message}`, 'ERROR'); }); } // Add auth rule via SimplePool (kind 23456 event) - FIXED to match working test pattern async function addAuthRuleViaWebSocket(ruleData) { if (!isLoggedIn || !userPubkey) { throw new Error('Must be logged in to add auth rules'); } if (!relayPool) { throw new Error('SimplePool connection not available'); } try { log(`Adding auth rule: ${ruleData.rule_type} - ${ruleData.pattern_value.substring(0, 16)}...`, 'INFO'); // Map client-side rule types to command array format (matching working tests) let commandRuleType, commandPatternType; switch (ruleData.rule_type) { case 'pubkey_blacklist': commandRuleType = 'blacklist'; commandPatternType = 'pubkey'; break; case 'pubkey_whitelist': commandRuleType = 'whitelist'; commandPatternType = 'pubkey'; break; case 'hash_blacklist': commandRuleType = 'blacklist'; commandPatternType = 'hash'; break; default: throw new Error(`Unknown rule type: ${ruleData.rule_type}`); } // Create command array in the same format as working tests // Format: ["blacklist", "pubkey", "abc123..."] or ["whitelist", "pubkey", "def456..."] const command_array = [commandRuleType, commandPatternType, ruleData.pattern_value]; // Encrypt the command array directly using NIP-44 const encrypted_content = await encryptForRelay(JSON.stringify(command_array)); if (!encrypted_content) { throw new Error('Failed to encrypt command array'); } // Create single kind 23456 admin event const authEvent = { kind: 23456, pubkey: userPubkey, created_at: Math.floor(Date.now() / 1000), tags: [["p", getRelayPubkey()]], content: encrypted_content }; // DEBUG: Log the complete event structure being sent console.log('=== AUTH RULE EVENT DEBUG (Administrator API) ==='); console.log('Original Rule Data:', ruleData); console.log('Command Array:', command_array); console.log('Encrypted Content:', encrypted_content.substring(0, 50) + '...'); console.log('Auth Event (before signing):', JSON.stringify(authEvent, null, 2)); console.log('=== END AUTH RULE EVENT DEBUG ==='); // Sign the event const signedEvent = await window.nostr.signEvent(authEvent); if (!signedEvent || !signedEvent.sig) { throw new Error('Event signing failed'); } // Publish via SimplePool with detailed error diagnostics const url = relayConnectionUrl.value.trim(); const publishPromises = relayPool.publish([url], signedEvent); // Use Promise.allSettled to capture per-relay outcomes instead of Promise.any const results = await Promise.allSettled(publishPromises); // Log detailed publish results for diagnostics let successCount = 0; results.forEach((result, index) => { if (result.status === 'fulfilled') { successCount++; console.log(`✅ Add Auth Rule Relay ${index} (${url}): Event published successfully`); if (typeof logTestEvent === 'function') { logTestEvent('INFO', `Add auth rule relay ${index} publish success`, 'PUBLISH'); } } else { console.error(`❌ Add Auth Rule Relay ${index} (${url}): Publish failed:`, result.reason); if (typeof logTestEvent === 'function') { logTestEvent('ERROR', `Add auth rule relay ${index} publish failed: ${result.reason?.message || result.reason}`, 'PUBLISH'); } } }); // Throw error if all relays failed if (successCount === 0) { const errorDetails = results.map((r, i) => `Relay ${i}: ${r.reason?.message || r.reason}`).join('; '); throw new Error(`All relays rejected add auth rule event. Details: ${errorDetails}`); } log('Auth rule added successfully', 'INFO'); } catch (error) { log(`Failed to add auth rule: ${error.message}`, 'ERROR'); throw error; } } // ================================ // TEST FUNCTIONS FOR ADMIN API // ================================ // Test event logging function function logTestEvent(direction, message, type = 'INFO') { const testLog = document.getElementById('test-event-log'); if (!testLog) return; const timestamp = new Date().toISOString().split('T')[1].split('.')[0]; const logEntry = document.createElement('div'); logEntry.className = 'log-entry'; const directionColor = direction === 'SENT' ? '#007bff' : '#28a745'; logEntry.innerHTML = ` ${timestamp} [${direction}] [${type}] ${message} `; testLog.appendChild(logEntry); testLog.scrollTop = testLog.scrollHeight; } // Test: Get Auth Rules async function testGetAuthRules() { if (!isLoggedIn || !userPubkey) { logTestEvent('ERROR', 'Must be logged in to test admin API', 'ERROR'); return; } if (!relayPool) { logTestEvent('ERROR', 'SimplePool connection not available', 'ERROR'); return; } try { logTestEvent('INFO', 'Testing Get Auth Rules command...', 'TEST'); // Create command array for getting auth rules const command_array = '["auth_query", "all"]'; // Encrypt the command content using NIP-44 const encrypted_content = await encryptForRelay(command_array); if (!encrypted_content) { throw new Error('Failed to encrypt auth query command'); } // Create kind 23456 admin event const authEvent = { kind: 23456, pubkey: userPubkey, created_at: Math.floor(Date.now() / 1000), tags: [ ["p", getRelayPubkey()] ], content: encrypted_content }; // Sign the event const signedEvent = await window.nostr.signEvent(authEvent); if (!signedEvent || !signedEvent.sig) { throw new Error('Event signing failed'); } logTestEvent('SENT', `Get Auth Rules event: ${JSON.stringify(signedEvent)}`, 'EVENT'); // Publish via SimplePool with detailed error diagnostics const url = relayConnectionUrl.value.trim(); const publishPromises = relayPool.publish([url], signedEvent); // Use Promise.allSettled to capture per-relay outcomes instead of Promise.any const results = await Promise.allSettled(publishPromises); // Log detailed publish results for diagnostics let successCount = 0; results.forEach((result, index) => { if (result.status === 'fulfilled') { successCount++; logTestEvent('INFO', `Test Add Blacklist relay ${index} publish success`, 'PUBLISH'); } else { logTestEvent('ERROR', `Test Add Blacklist relay ${index} publish failed: ${result.reason?.message || result.reason}`, 'PUBLISH'); } }); // Throw error if all relays failed if (successCount === 0) { const errorDetails = results.map((r, i) => `Relay ${i}: ${r.reason?.message || r.reason}`).join('; '); throw new Error(`All relays rejected test add blacklist event. Details: ${errorDetails}`); } logTestEvent('INFO', 'Get Auth Rules command sent successfully', 'SUCCESS'); } catch (error) { logTestEvent('ERROR', `Get Auth Rules test failed: ${error.message}`, 'ERROR'); } } // Test: Clear All Auth Rules async function testClearAuthRules() { if (!isLoggedIn || !userPubkey) { logTestEvent('ERROR', 'Must be logged in to test admin API', 'ERROR'); return; } if (!relayPool) { logTestEvent('ERROR', 'SimplePool connection not available', 'ERROR'); return; } try { logTestEvent('INFO', 'Testing Clear All Auth Rules command...', 'TEST'); // Create command array for clearing auth rules const command_array = '["system_command", "clear_all_auth_rules"]'; // Encrypt the command content using NIP-44 const encrypted_content = await encryptForRelay(command_array); if (!encrypted_content) { throw new Error('Failed to encrypt clear auth rules command'); } // Create kind 23456 admin event const authEvent = { kind: 23456, pubkey: userPubkey, created_at: Math.floor(Date.now() / 1000), tags: [ ["p", getRelayPubkey()] ], content: encrypted_content }; // Sign the event const signedEvent = await window.nostr.signEvent(authEvent); if (!signedEvent || !signedEvent.sig) { throw new Error('Event signing failed'); } logTestEvent('SENT', `Clear Auth Rules event: ${JSON.stringify(signedEvent)}`, 'EVENT'); // Publish via SimplePool with detailed error diagnostics const url = relayConnectionUrl.value.trim(); const publishPromises = relayPool.publish([url], signedEvent); // Use Promise.allSettled to capture per-relay outcomes instead of Promise.any const results = await Promise.allSettled(publishPromises); // Log detailed publish results for diagnostics let successCount = 0; results.forEach((result, index) => { if (result.status === 'fulfilled') { successCount++; logTestEvent('INFO', `Test Add Whitelist relay ${index} publish success`, 'PUBLISH'); } else { logTestEvent('ERROR', `Test Add Whitelist relay ${index} publish failed: ${result.reason?.message || result.reason}`, 'PUBLISH'); } }); // Throw error if all relays failed if (successCount === 0) { const errorDetails = results.map((r, i) => `Relay ${i}: ${r.reason?.message || r.reason}`).join('; '); throw new Error(`All relays rejected test add whitelist event. Details: ${errorDetails}`); } logTestEvent('INFO', 'Clear Auth Rules command sent successfully', 'SUCCESS'); } catch (error) { logTestEvent('ERROR', `Clear Auth Rules test failed: ${error.message}`, 'ERROR'); } } // Test: Add Blacklist async function testAddBlacklist() { const testPubkeyInput = document.getElementById('test-pubkey-input'); let testPubkey = testPubkeyInput ? testPubkeyInput.value.trim() : ''; // Use a default test pubkey if none provided if (!testPubkey) { testPubkey = '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef'; logTestEvent('INFO', `Using default test pubkey: ${testPubkey}`, 'INFO'); } if (!isLoggedIn || !userPubkey) { logTestEvent('ERROR', 'Must be logged in to test admin API', 'ERROR'); return; } if (!relayPool) { logTestEvent('ERROR', 'SimplePool connection not available', 'ERROR'); return; } try { logTestEvent('INFO', `Testing Add Blacklist for pubkey: ${testPubkey.substring(0, 16)}...`, 'TEST'); // Create command array for adding blacklist rule const command_array = `["blacklist", "pubkey", "${testPubkey}"]`; // Encrypt the command content using NIP-44 const encrypted_content = await encryptForRelay(command_array); if (!encrypted_content) { throw new Error('Failed to encrypt blacklist command'); } // Create kind 23456 admin event const authEvent = { kind: 23456, pubkey: userPubkey, created_at: Math.floor(Date.now() / 1000), tags: [ ["p", getRelayPubkey()] ], content: encrypted_content }; // Sign the event const signedEvent = await window.nostr.signEvent(authEvent); if (!signedEvent || !signedEvent.sig) { throw new Error('Event signing failed'); } logTestEvent('SENT', `Add Blacklist event: ${JSON.stringify(signedEvent)}`, 'EVENT'); // Publish via SimplePool with detailed error diagnostics const url = relayConnectionUrl.value.trim(); const publishPromises = relayPool.publish([url], signedEvent); // Use Promise.allSettled to capture per-relay outcomes instead of Promise.any const results = await Promise.allSettled(publishPromises); // Log detailed publish results for diagnostics let successCount = 0; results.forEach((result, index) => { if (result.status === 'fulfilled') { successCount++; logTestEvent('INFO', `Test Config Query relay ${index} publish success`, 'PUBLISH'); } else { logTestEvent('ERROR', `Test Config Query relay ${index} publish failed: ${result.reason?.message || result.reason}`, 'PUBLISH'); } }); // Throw error if all relays failed if (successCount === 0) { const errorDetails = results.map((r, i) => `Relay ${i}: ${r.reason?.message || r.reason}`).join('; '); throw new Error(`All relays rejected test config query event. Details: ${errorDetails}`); } logTestEvent('INFO', 'Add Blacklist command sent successfully', 'SUCCESS'); } catch (error) { logTestEvent('ERROR', `Add Blacklist test failed: ${error.message}`, 'ERROR'); } } // Test: Add Whitelist async function testAddWhitelist() { const testPubkeyInput = document.getElementById('test-pubkey-input'); let testPubkey = testPubkeyInput ? testPubkeyInput.value.trim() : ''; // Use a default test pubkey if none provided if (!testPubkey) { testPubkey = 'abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890'; logTestEvent('INFO', `Using default test pubkey: ${testPubkey}`, 'INFO'); } if (!isLoggedIn || !userPubkey) { logTestEvent('ERROR', 'Must be logged in to test admin API', 'ERROR'); return; } if (!relayPool) { logTestEvent('ERROR', 'SimplePool connection not available', 'ERROR'); return; } try { logTestEvent('INFO', `Testing Add Whitelist for pubkey: ${testPubkey.substring(0, 16)}...`, 'TEST'); // Create command array for adding whitelist rule const command_array = `["whitelist", "pubkey", "${testPubkey}"]`; // Encrypt the command content using NIP-44 const encrypted_content = await encryptForRelay(command_array); if (!encrypted_content) { throw new Error('Failed to encrypt whitelist command'); } // Create kind 23456 admin event const authEvent = { kind: 23456, pubkey: userPubkey, created_at: Math.floor(Date.now() / 1000), tags: [ ["p", getRelayPubkey()] ], content: encrypted_content }; // Sign the event const signedEvent = await window.nostr.signEvent(authEvent); if (!signedEvent || !signedEvent.sig) { throw new Error('Event signing failed'); } logTestEvent('SENT', `Add Whitelist event: ${JSON.stringify(signedEvent)}`, 'EVENT'); // Publish via SimplePool const url = relayConnectionUrl.value.trim(); const publishPromises = relayPool.publish([url], signedEvent); // Use Promise.allSettled to capture per-relay outcomes instead of Promise.any const results = await Promise.allSettled(publishPromises); // Log detailed publish results for diagnostics let successCount = 0; results.forEach((result, index) => { if (result.status === 'fulfilled') { successCount++; logTestEvent('INFO', `Test Post Event relay ${index} publish success`, 'PUBLISH'); } else { logTestEvent('ERROR', `Test Post Event relay ${index} publish failed: ${result.reason?.message || result.reason}`, 'PUBLISH'); } }); // Throw error if all relays failed if (successCount === 0) { const errorDetails = results.map((r, i) => `Relay ${i}: ${r.reason?.message || r.reason}`).join('; '); throw new Error(`All relays rejected test post event. Details: ${errorDetails}`); } logTestEvent('INFO', 'Add Whitelist command sent successfully', 'SUCCESS'); } catch (error) { logTestEvent('ERROR', `Add Whitelist test failed: ${error.message}`, 'ERROR'); } } // Test: Config Query async function testConfigQuery() { if (!isLoggedIn || !userPubkey) { logTestEvent('ERROR', 'Must be logged in to test admin API', 'ERROR'); return; } if (!relayPool) { logTestEvent('ERROR', 'SimplePool connection not available', 'ERROR'); return; } try { logTestEvent('INFO', 'Testing Config Query command...', 'TEST'); // Create command array for getting configuration const command_array = '["config_query", "all"]'; // Encrypt the command content using NIP-44 const encrypted_content = await encryptForRelay(command_array); if (!encrypted_content) { throw new Error('Failed to encrypt config query command'); } // Create kind 23456 admin event const configEvent = { kind: 23456, pubkey: userPubkey, created_at: Math.floor(Date.now() / 1000), tags: [ ["p", getRelayPubkey()] ], content: encrypted_content }; // Sign the event const signedEvent = await window.nostr.signEvent(configEvent); if (!signedEvent || !signedEvent.sig) { throw new Error('Event signing failed'); } logTestEvent('SENT', `Config Query event: ${JSON.stringify(signedEvent)}`, 'EVENT'); // Publish via SimplePool with detailed error diagnostics const url = relayUrl.value.trim(); const publishPromises = relayPool.publish([url], signedEvent); // Use Promise.allSettled to capture per-relay outcomes instead of Promise.any const results = await Promise.allSettled(publishPromises); // Log detailed publish results for diagnostics let successCount = 0; results.forEach((result, index) => { if (result.status === 'fulfilled') { successCount++; logTestEvent('INFO', `Test Config Query relay ${index} publish success`, 'PUBLISH'); } else { logTestEvent('ERROR', `Test Config Query relay ${index} publish failed: ${result.reason?.message || result.reason}`, 'PUBLISH'); } }); // Throw error if all relays failed if (successCount === 0) { const errorDetails = results.map((r, i) => `Relay ${i}: ${r.reason?.message || r.reason}`).join('; '); throw new Error(`All relays rejected test config query event. Details: ${errorDetails}`); } logTestEvent('INFO', 'Config Query command sent successfully', 'SUCCESS'); } catch (error) { logTestEvent('ERROR', `Config Query test failed: ${error.message}`, 'ERROR'); } } // Test: Post Basic Event async function testPostEvent() { if (!isLoggedIn || !userPubkey) { logTestEvent('ERROR', 'Must be logged in to test event posting', 'ERROR'); return; } if (!relayPool) { logTestEvent('ERROR', 'SimplePool connection not available', 'ERROR'); return; } try { logTestEvent('INFO', 'Testing basic event posting...', 'TEST'); // Create a simple kind 1 text note event const testEvent = { kind: 1, pubkey: userPubkey, created_at: Math.floor(Date.now() / 1000), tags: [ ["t", "test"], ["client", "c-relay-admin-api"] ], content: `Test event from C-Relay Admin API at ${new Date().toISOString()}` }; logTestEvent('SENT', `Test event (before signing): ${JSON.stringify(testEvent)}`, 'EVENT'); // Sign the event using NIP-07 const signedEvent = await window.nostr.signEvent(testEvent); if (!signedEvent || !signedEvent.sig) { throw new Error('Event signing failed'); } logTestEvent('SENT', `Signed test event: ${JSON.stringify(signedEvent)}`, 'EVENT'); // Publish via SimplePool to the same relay with detailed error diagnostics const url = relayConnectionUrl.value.trim(); logTestEvent('INFO', `Publishing to relay: ${url}`, 'INFO'); const publishPromises = relayPool.publish([url], signedEvent); // Use Promise.allSettled to capture per-relay outcomes instead of Promise.any const results = await Promise.allSettled(publishPromises); // Log detailed publish results for diagnostics let successCount = 0; results.forEach((result, index) => { if (result.status === 'fulfilled') { successCount++; logTestEvent('INFO', `Test Post Event relay ${index} publish success`, 'PUBLISH'); } else { logTestEvent('ERROR', `Test Post Event relay ${index} publish failed: ${result.reason?.message || result.reason}`, 'PUBLISH'); } }); // Throw error if all relays failed if (successCount === 0) { const errorDetails = results.map((r, i) => `Relay ${i}: ${r.reason?.message || r.reason}`).join('; '); throw new Error(`All relays rejected test post event. Details: ${errorDetails}`); } logTestEvent('INFO', 'Test event published successfully!', 'SUCCESS'); logTestEvent('INFO', 'Check if the event appears in the subscription above...', 'INFO'); } catch (error) { logTestEvent('ERROR', `Post Event test failed: ${error.message}`, 'ERROR'); console.error('Post Event test error:', error); } } // Helper function to encrypt content for relay using NIP-44 async function encryptForRelay(content) { try { logTestEvent('INFO', `Encrypting content: ${content}`, 'DEBUG'); // Get the relay public key for encryption const relayPubkey = getRelayPubkey(); // Check if we have access to NIP-44 encryption via nostr-tools if (!window.NostrTools || !window.NostrTools.nip44) { throw new Error('NIP-44 encryption not available - nostr-tools library missing'); } // Get user's private key for encryption // We need to use the NIP-07 extension to get the private key if (!window.nostr || !window.nostr.nip44) { throw new Error('NIP-44 encryption not available via NIP-07 extension'); } // Use NIP-07 extension's NIP-44 encrypt method const encrypted_content = await window.nostr.nip44.encrypt(relayPubkey, content); if (!encrypted_content) { throw new Error('NIP-44 encryption returned empty result'); } logTestEvent('INFO', `Successfully encrypted content using NIP-44`, 'DEBUG'); logTestEvent('INFO', `Encrypted content: ${encrypted_content.substring(0, 50)}...`, 'DEBUG'); return encrypted_content; } catch (error) { logTestEvent('ERROR', `NIP-44 encryption failed: ${error.message}`, 'ERROR'); // Fallback: Try using nostr-tools directly if NIP-07 fails try { logTestEvent('INFO', 'Attempting fallback encryption with nostr-tools...', 'DEBUG'); if (!window.NostrTools || !window.NostrTools.nip44) { throw new Error('nostr-tools NIP-44 not available'); } // We need the user's private key, but we can't get it directly // This is a security limitation - we should use NIP-07 throw new Error('Cannot access private key for direct encryption - use NIP-07 extension'); } catch (fallbackError) { logTestEvent('ERROR', `Fallback encryption failed: ${fallbackError.message}`, 'ERROR'); return null; } } } // Send NIP-17 Direct Message to relay using NIP-59 layering async function sendNIP17DM() { if (!isLoggedIn || !userPubkey) { log('Must be logged in to send DM', 'ERROR'); return; } if (!isRelayConnected || !relayPubkey) { log('Must be connected to relay to send DM', 'ERROR'); return; } const message = dmOutbox.value.trim(); if (!message) { log('Please enter a message to send', 'ERROR'); return; } // Capability checks if (!window.nostr || !window.nostr.nip44 || !window.nostr.signEvent) { log('NIP-17 DMs require a NIP-07 extension with NIP-44 support', 'ERROR'); alert('NIP-17 DMs require a NIP-07 extension with NIP-44 support. Please install and configure a compatible extension.'); return; } if (!window.NostrTools || !window.NostrTools.generateSecretKey || !window.NostrTools.getPublicKey || !window.NostrTools.finalizeEvent) { log('NostrTools library not available for ephemeral key operations', 'ERROR'); alert('NostrTools library not available. Please ensure nostr.bundle.js is loaded.'); return; } try { log(`Sending NIP-17 DM to relay: ${message.substring(0, 50)}...`, 'INFO'); // Step 1: Build unsigned rumor (kind 14) const rumor = { kind: 14, pubkey: userPubkey, created_at: Math.floor(Date.now() / 1000), // Canonical time for rumor tags: [["p", relayPubkey]], content: message }; // NOTE: Rumor remains unsigned per NIP-59 log('Rumor built (unsigned), creating seal...', 'INFO'); // Step 2: Create seal (kind 13) const seal = { kind: 13, pubkey: userPubkey, created_at: randomNow(), // Randomized to past for metadata protection tags: [], // Empty tags per NIP-59 content: await window.nostr.nip44.encrypt(relayPubkey, JSON.stringify(rumor)) }; // Sign seal with long-term key const signedSeal = await window.nostr.signEvent(seal); if (!signedSeal || !signedSeal.sig) { throw new Error('Failed to sign seal event'); } log('Seal created and signed, creating gift wrap...', 'INFO'); // Step 3: Create gift wrap (kind 1059) with ephemeral key const ephemeralPriv = window.NostrTools.generateSecretKey(); const ephemeralPub = window.NostrTools.getPublicKey(ephemeralPriv); const giftWrap = { kind: 1059, pubkey: ephemeralPub, created_at: randomNow(), // Randomized to past for metadata protection tags: [["p", relayPubkey]], content: await window.NostrTools.nip44.encrypt( JSON.stringify(signedSeal), window.NostrTools.nip44.getConversationKey(ephemeralPriv, relayPubkey) ) }; // Sign gift wrap with ephemeral key using finalizeEvent const signedGiftWrap = window.NostrTools.finalizeEvent(giftWrap, ephemeralPriv); if (!signedGiftWrap || !signedGiftWrap.sig) { throw new Error('Failed to sign gift wrap event'); } log('NIP-17 DM event created and signed with ephemeral key, publishing...', 'INFO'); // Publish via SimplePool const url = relayConnectionUrl.value.trim(); const publishPromises = relayPool.publish([url], signedGiftWrap); // Use Promise.allSettled to capture per-relay outcomes const results = await Promise.allSettled(publishPromises); // Log detailed publish results let successCount = 0; results.forEach((result, index) => { if (result.status === 'fulfilled') { successCount++; log(`✅ NIP-17 DM published successfully to relay ${index}`, 'INFO'); } else { log(`❌ NIP-17 DM failed on relay ${index}: ${result.reason?.message || result.reason}`, 'ERROR'); } }); if (successCount === 0) { const errorDetails = results.map((r, i) => `Relay ${i}: ${r.reason?.message || r.reason}`).join('; '); throw new Error(`All relays rejected NIP-17 DM event. Details: ${errorDetails}`); } // Clear the outbox and show success dmOutbox.value = ''; log('NIP-17 DM sent successfully', 'INFO'); // Add to inbox for display addMessageToInbox('sent', message, new Date().toLocaleString()); } catch (error) { log(`Failed to send NIP-17 DM: ${error.message}`, 'ERROR'); } } // Add message to inbox display function addMessageToInbox(direction, message, timestamp, pubkey = null) { if (!dmInbox) return; const messageDiv = document.createElement('div'); messageDiv.className = 'log-entry'; const directionColor = direction === 'sent' ? '#007bff' : '#28a745'; // Convert newlines to
tags for proper HTML display const formattedMessage = message.replace(/\n/g, '
'); // Add pubkey display for received messages let pubkeyDisplay = ''; if (pubkey && direction === 'received') { try { const npub = window.NostrTools.nip19.npubEncode(pubkey); pubkeyDisplay = ` (${npub})`; } catch (error) { console.error('Failed to encode pubkey to npub:', error); } } messageDiv.innerHTML = ` ${timestamp} [${direction.toUpperCase()}] ${formattedMessage}${pubkeyDisplay} `; // Remove the "No messages received yet" placeholder if it exists const placeholder = dmInbox.querySelector('.log-entry'); if (placeholder && placeholder.textContent === 'No messages received yet.') { dmInbox.innerHTML = ''; } // Add new message at the top dmInbox.insertBefore(messageDiv, dmInbox.firstChild); // Limit to last 50 messages while (dmInbox.children.length > 50) { dmInbox.removeChild(dmInbox.lastChild); } } // Update relay info in header function updateRelayInfoInHeader() { const relayNameElement = document.getElementById('relay-name'); const relayPubkeyElement = document.getElementById('relay-pubkey'); const relayDescriptionElement = document.getElementById('relay-description'); if (!relayNameElement || !relayPubkeyElement || !relayDescriptionElement) { return; } // Get relay info from NIP-11 data or use defaults const relayInfo = getRelayInfo(); const relayName = relayInfo.name || 'C-Relay'; const relayDescription = relayInfo.description || 'Nostr Relay'; // Convert relay pubkey to npub let relayNpub = 'Loading...'; if (relayPubkey) { try { relayNpub = window.NostrTools.nip19.npubEncode(relayPubkey); } catch (error) { console.log('Failed to encode relay pubkey to npub:', error.message); relayNpub = relayPubkey.substring(0, 16) + '...'; } } // Format npub into 3 lines of 21 characters each, with spaces dividing each line into 3 groups of 7 characters let formattedNpub = relayNpub; if (relayNpub.length === 63) { const line1 = relayNpub.substring(0, 7) + ' ' + relayNpub.substring(7, 14) + ' ' + relayNpub.substring(14, 21); const line2 = relayNpub.substring(21, 28) + ' ' + relayNpub.substring(28, 35) + ' ' + relayNpub.substring(35, 42); const line3 = relayNpub.substring(42, 49) + ' ' + relayNpub.substring(49, 56) + ' ' + relayNpub.substring(56, 63); formattedNpub = line1 + '\n' + line2 + '\n' + line3; } relayNameElement.textContent = relayName; relayPubkeyElement.textContent = formattedNpub; relayDescriptionElement.textContent = relayDescription; } // Global variable to store relay info from NIP-11 or config let relayInfoData = null; // Helper function to get relay info from stored data function getRelayInfo() { // Return stored relay info if available, otherwise defaults if (relayInfoData) { return relayInfoData; } // Default values return { name: 'C-Relay', description: 'Nostr Relay', pubkey: relayPubkey }; } // Update stored relay info when config is loaded function updateStoredRelayInfo(configData) { if (configData && configData.data) { // Extract relay info from config data const relayName = configData.data.find(item => item.key === 'relay_name')?.value || 'C-Relay'; const relayDescription = configData.data.find(item => item.key === 'relay_description')?.value || 'Nostr Relay'; relayInfoData = { name: relayName, description: relayDescription, pubkey: relayPubkey }; // Update header immediately updateRelayInfoInHeader(); } } // Helper function to get relay pubkey function getRelayPubkey() { // Use the dynamically fetched relay pubkey if available if (relayPubkey) { return relayPubkey; } // No fallback - throw error if relay pubkey not available throw new Error('Relay pubkey not available. Please connect to relay first.'); } // Enhanced SimplePool message handler to capture test responses function enhancePoolForTesting() { // SimplePool handles message parsing automatically, so we just need to // ensure our event handlers log appropriately. This is already done // in the subscription onevent callback. console.log('SimplePool enhanced for testing - automatic message handling enabled'); } // Generate random test pubkey function function generateRandomTestKey() { // Generate 32 random bytes (64 hex characters) for a valid pubkey const randomBytes = new Uint8Array(32); crypto.getRandomValues(randomBytes); // Convert to hex string const hexPubkey = Array.from(randomBytes) .map(b => b.toString(16).padStart(2, '0')) .join(''); // Set the generated key in the input field const testPubkeyInput = document.getElementById('test-pubkey-input'); if (testPubkeyInput) { testPubkeyInput.value = hexPubkey; logTestEvent('INFO', `Generated random test pubkey: ${hexPubkey.substring(0, 16)}...`, 'KEYGEN'); } return hexPubkey; } // ================================ // DATABASE STATISTICS FUNCTIONS // ================================ // Send restart command to restart the relay using Administrator API async function sendRestartCommand() { if (!isLoggedIn || !userPubkey) { log('Must be logged in to restart relay', 'ERROR'); return; } if (!relayPool) { log('SimplePool connection not available', 'ERROR'); return; } try { log('Sending restart command to relay...', 'INFO'); // Create command array for restart const command_array = ["system_command", "restart"]; // Encrypt the command array directly using NIP-44 const encrypted_content = await encryptForRelay(JSON.stringify(command_array)); if (!encrypted_content) { throw new Error('Failed to encrypt command array'); } // Create single kind 23456 admin event const restartEvent = { kind: 23456, pubkey: userPubkey, created_at: Math.floor(Date.now() / 1000), tags: [["p", getRelayPubkey()]], content: encrypted_content }; // Sign the event const signedEvent = await window.nostr.signEvent(restartEvent); if (!signedEvent || !signedEvent.sig) { throw new Error('Event signing failed'); } // Publish via SimplePool const url = relayConnectionUrl.value.trim(); const publishPromises = relayPool.publish([url], signedEvent); // Use Promise.allSettled to capture per-relay outcomes const results = await Promise.allSettled(publishPromises); // Check if any relay accepted the event let successCount = 0; results.forEach((result, index) => { if (result.status === 'fulfilled') { successCount++; log(`Restart command published successfully to relay ${index}`, 'INFO'); } else { log(`Restart command failed on relay ${index}: ${result.reason?.message || result.reason}`, 'ERROR'); } }); if (successCount === 0) { const errorDetails = results.map((r, i) => `Relay ${i}: ${r.reason?.message || r.reason}`).join('; '); throw new Error(`All relays rejected restart command. Details: ${errorDetails}`); } log('Restart command sent successfully - relay should restart shortly...', 'INFO'); // Update connection status to indicate restart is in progress updateRelayConnectionStatus('connecting'); relayConnectionStatus.textContent = 'RESTARTING...'; // The relay will disconnect and need to be reconnected after restart // This will be handled by the WebSocket disconnection event } catch (error) { log(`Failed to send restart command: ${error.message}`, 'ERROR'); updateRelayConnectionStatus('error'); } } // Send stats_query command to get database statistics using Administrator API (inner events) async function sendStatsQuery() { if (!isLoggedIn || !userPubkey) { log('Must be logged in to query database statistics', 'ERROR'); updateStatsStatus('error', 'Not logged in'); return; } if (!relayPool) { log('SimplePool connection not available', 'ERROR'); updateStatsStatus('error', 'No relay connection'); return; } try { updateStatsStatus('loading', 'Querying database...'); // Create command array for stats query const command_array = ["stats_query", "all"]; // Encrypt the command array directly using NIP-44 const encrypted_content = await encryptForRelay(JSON.stringify(command_array)); if (!encrypted_content) { throw new Error('Failed to encrypt command array'); } // Create single kind 23456 admin event const statsEvent = { kind: 23456, pubkey: userPubkey, created_at: Math.floor(Date.now() / 1000), tags: [["p", getRelayPubkey()]], content: encrypted_content }; // Sign the event const signedEvent = await window.nostr.signEvent(statsEvent); if (!signedEvent || !signedEvent.sig) { throw new Error('Event signing failed'); } log('Sending stats query command...', 'INFO'); // Publish via SimplePool const url = relayConnectionUrl.value.trim(); const publishPromises = relayPool.publish([url], signedEvent); // Use Promise.allSettled to capture per-relay outcomes const results = await Promise.allSettled(publishPromises); // Check if any relay accepted the event let successCount = 0; results.forEach((result, index) => { if (result.status === 'fulfilled') { successCount++; log(`Stats query published successfully to relay ${index}`, 'INFO'); } else { log(`Stats query failed on relay ${index}: ${result.reason?.message || result.reason}`, 'ERROR'); } }); if (successCount === 0) { const errorDetails = results.map((r, i) => `Relay ${i}: ${r.reason?.message || r.reason}`).join('; '); throw new Error(`All relays rejected stats query event. Details: ${errorDetails}`); } log('Stats query command sent successfully - waiting for response...', 'INFO'); updateStatsStatus('waiting', 'Waiting for response...'); } catch (error) { log(`Failed to send stats query: ${error.message}`, 'ERROR'); updateStatsStatus('error', error.message); } } // Handle stats_query response and populate tables function handleStatsQueryResponse(responseData) { try { log('Processing stats query response...', 'INFO'); console.log('Stats response data:', responseData); if (responseData.query_type !== 'stats_query') { log('Ignoring non-stats response', 'WARNING'); return; } // Populate overview table populateStatsOverview(responseData); // Populate event kinds table populateStatsKinds(responseData); // Populate time-based statistics populateStatsTime(responseData); // Populate top pubkeys table populateStatsPubkeys(responseData); updateStatsStatus('loaded'); log('Database statistics updated successfully', 'INFO'); } catch (error) { log(`Error processing stats response: ${error.message}`, 'ERROR'); updateStatsStatus('error', 'Failed to process response'); } } // Update statistics display from real-time monitoring event function updateStatsFromMonitoringEvent(monitoringData) { try { log('Updating stats from monitoring event...', 'INFO'); console.log('Monitoring data:', monitoringData); if (monitoringData.data_type !== 'event_kinds') { log('Ignoring monitoring event with different data type', 'WARNING'); return; } // Update total events count and track rate for chart if (monitoringData.total_events !== undefined) { const currentTotal = monitoringData.total_events; updateStatsCell('total-events', currentTotal.toString()); // Calculate new events since last update for chart if (previousTotalEvents > 0) { const newEvents = currentTotal - previousTotalEvents; if (newEvents > 0 && eventRateChart) { console.log(`Adding ${newEvents} new events to rate chart (${currentTotal} - ${previousTotalEvents})`); eventRateChart.addValue(newEvents); } } // Update previous total for next calculation previousTotalEvents = currentTotal; } // Update event kinds table with real-time data if (monitoringData.kinds && Array.isArray(monitoringData.kinds)) { populateStatsKindsFromMonitoring(monitoringData.kinds, monitoringData.total_events); } log('Real-time statistics updated from monitoring event', 'INFO'); } catch (error) { log(`Error updating stats from monitoring event: ${error.message}`, 'ERROR'); } } // Update statistics display from time_stats monitoring event function updateStatsFromTimeMonitoringEvent(monitoringData) { try { log('Updating time stats from monitoring event...', 'INFO'); console.log('Time monitoring data:', monitoringData); if (monitoringData.data_type !== 'time_stats') { log('Ignoring time monitoring event with different data type', 'WARNING'); return; } // Update time-based statistics table with real-time data if (monitoringData.periods && Array.isArray(monitoringData.periods)) { // Use the existing populateStatsTime function which expects the nested time_stats object const timeStats = { last_24h: 0, last_7d: 0, last_30d: 0 }; // Extract values from periods array monitoringData.periods.forEach(period => { if (period.period === '24h') timeStats.last_24h = period.event_count; else if (period.period === '7d') timeStats.last_7d = period.event_count; else if (period.period === '30d') timeStats.last_30d = period.event_count; }); populateStatsTime({ time_stats: timeStats }); } log('Real-time time statistics updated from monitoring event', 'INFO'); } catch (error) { log(`Error updating time stats from monitoring event: ${error.message}`, 'ERROR'); } } // Update statistics display from top_pubkeys monitoring event function updateStatsFromTopPubkeysMonitoringEvent(monitoringData) { try { log('Updating top pubkeys from monitoring event...', 'INFO'); console.log('Top pubkeys monitoring data:', monitoringData); if (monitoringData.data_type !== 'top_pubkeys') { log('Ignoring top pubkeys monitoring event with different data type', 'WARNING'); return; } // Update top pubkeys table with real-time data if (monitoringData.pubkeys && Array.isArray(monitoringData.pubkeys)) { // Pass total_events from monitoring data to the function populateStatsPubkeysFromMonitoring(monitoringData.pubkeys, monitoringData.total_events || 0); } log('Real-time top pubkeys statistics updated from monitoring event', 'INFO'); } catch (error) { log(`Error updating top pubkeys from monitoring event: ${error.message}`, 'ERROR'); } } // Update statistics display from active_subscriptions monitoring event function updateStatsFromActiveSubscriptionsMonitoringEvent(monitoringData) { try { log('Updating active subscriptions from monitoring event...', 'INFO'); console.log('Active subscriptions monitoring data:', monitoringData); if (monitoringData.data_type !== 'active_subscriptions') { log('Ignoring active subscriptions monitoring event with different data type', 'WARNING'); return; } // Update active subscriptions cell with real-time data // The data is nested under monitoringData.data.total_subscriptions if (monitoringData.data && monitoringData.data.total_subscriptions !== undefined) { updateStatsCell('active-subscriptions', monitoringData.data.total_subscriptions.toString()); } log('Real-time active subscriptions statistics updated from monitoring event', 'INFO'); } catch (error) { log(`Error updating active subscriptions from monitoring event: ${error.message}`, 'ERROR'); } } // Update statistics display from subscription_details monitoring event function updateStatsFromSubscriptionDetailsMonitoringEvent(monitoringData) { try { log('Updating subscription details from monitoring event...', 'INFO'); console.log('Subscription details monitoring data:', monitoringData); if (monitoringData.data_type !== 'subscription_details') { log('Ignoring subscription details monitoring event with different data type', 'WARNING'); return; } // Update subscription details table with real-time data if (monitoringData.data && Array.isArray(monitoringData.data.subscriptions)) { populateSubscriptionDetailsTable(monitoringData.data.subscriptions); } log('Real-time subscription details statistics updated from monitoring event', 'INFO'); } catch (error) { log(`Error updating subscription details from monitoring event: ${error.message}`, 'ERROR'); } } // Populate event kinds table from monitoring data function populateStatsKindsFromMonitoring(kindsData, totalEvents) { const tableBody = document.getElementById('stats-kinds-table-body'); if (!tableBody) return; tableBody.innerHTML = ''; if (kindsData.length === 0) { const row = document.createElement('tr'); row.innerHTML = 'No event data'; tableBody.appendChild(row); return; } kindsData.forEach(kind => { const row = document.createElement('tr'); const percentage = totalEvents > 0 ? ((kind.count / totalEvents) * 100).toFixed(1) : '0.0'; row.innerHTML = ` ${kind.kind} ${kind.count} ${percentage}% `; tableBody.appendChild(row); }); } // Populate database overview table function populateStatsOverview(data) { if (!data) return; // Update individual cells with flash animation for changed values updateStatsCell('db-size', data.database_size_bytes ? formatFileSize(data.database_size_bytes) : '-'); updateStatsCell('total-events', data.total_events || '-'); updateStatsCell('oldest-event', data.database_created_at ? formatTimestamp(data.database_created_at) : '-'); updateStatsCell('newest-event', data.latest_event_at ? formatTimestamp(data.latest_event_at) : '-'); } // Populate event kinds distribution table function populateStatsKinds(data) { const tableBody = document.getElementById('stats-kinds-table-body'); if (!tableBody || !data.event_kinds) return; tableBody.innerHTML = ''; if (data.event_kinds.length === 0) { const row = document.createElement('tr'); row.innerHTML = 'No event data'; tableBody.appendChild(row); return; } data.event_kinds.forEach(kind => { const row = document.createElement('tr'); row.innerHTML = ` ${kind.kind} ${kind.count} ${kind.percentage}% `; tableBody.appendChild(row); }); } // Populate time-based statistics table function populateStatsTime(data) { if (!data) return; // Access the nested time_stats object from backend response const timeStats = data.time_stats || {}; // Update cells with flash animation for changed values updateStatsCell('events-24h', timeStats.last_24h || '0'); updateStatsCell('events-7d', timeStats.last_7d || '0'); updateStatsCell('events-30d', timeStats.last_30d || '0'); } // Populate top pubkeys table function populateStatsPubkeys(data) { const tableBody = document.getElementById('stats-pubkeys-table-body'); if (!tableBody || !data.top_pubkeys) return; tableBody.innerHTML = ''; if (data.top_pubkeys.length === 0) { const row = document.createElement('tr'); row.innerHTML = 'No pubkey data'; tableBody.appendChild(row); return; } data.top_pubkeys.forEach((pubkey, index) => { const row = document.createElement('tr'); // Convert hex pubkey to npub for display let displayPubkey = pubkey.pubkey || '-'; let npubLink = displayPubkey; try { if (pubkey.pubkey && pubkey.pubkey.length === 64 && /^[0-9a-fA-F]+$/.test(pubkey.pubkey)) { const npub = window.NostrTools.nip19.npubEncode(pubkey.pubkey); displayPubkey = npub; npubLink = `${npub}`; } } catch (error) { console.log('Failed to encode pubkey to npub:', error.message); } row.innerHTML = ` ${index + 1} ${npubLink} ${pubkey.event_count} ${pubkey.percentage}% `; tableBody.appendChild(row); }); } // Populate top pubkeys table from monitoring data function populateStatsPubkeysFromMonitoring(pubkeysData, totalEvents) { const tableBody = document.getElementById('stats-pubkeys-table-body'); if (!tableBody || !pubkeysData || !Array.isArray(pubkeysData)) return; tableBody.innerHTML = ''; if (pubkeysData.length === 0) { const row = document.createElement('tr'); row.innerHTML = 'No pubkey data'; tableBody.appendChild(row); return; } pubkeysData.forEach((pubkey, index) => { const row = document.createElement('tr'); // Convert hex pubkey to npub for display let displayPubkey = pubkey.pubkey || '-'; let npubLink = displayPubkey; try { if (pubkey.pubkey && pubkey.pubkey.length === 64 && /^[0-9a-fA-F]+$/.test(pubkey.pubkey)) { const npub = window.NostrTools.nip19.npubEncode(pubkey.pubkey); displayPubkey = npub; npubLink = `${npub}`; } } catch (error) { console.log('Failed to encode pubkey to npub:', error.message); } // Calculate percentage using totalEvents parameter const percentage = totalEvents > 0 ? ((pubkey.event_count / totalEvents) * 100).toFixed(1) : '0.0'; row.innerHTML = ` ${index + 1} ${npubLink} ${pubkey.event_count} ${percentage}% `; tableBody.appendChild(row); }); } // Populate subscription details table from monitoring data function populateSubscriptionDetailsTable(subscriptionsData) { const tableBody = document.getElementById('subscription-details-table-body'); if (!tableBody || !subscriptionsData || !Array.isArray(subscriptionsData)) return; tableBody.innerHTML = ''; if (subscriptionsData.length === 0) { const row = document.createElement('tr'); row.innerHTML = 'No active subscriptions'; tableBody.appendChild(row); return; } subscriptionsData.forEach((subscription, index) => { const row = document.createElement('tr'); // Calculate duration const now = Math.floor(Date.now() / 1000); const duration = now - subscription.created_at; const durationStr = formatDuration(duration); // Format client IP (show full IP for admin view) const clientIP = subscription.client_ip || 'unknown'; // Format status const status = subscription.active ? 'Active' : 'Inactive'; // Format filters (show actual filter details) let filtersDisplay = 'None'; if (subscription.filters && subscription.filters.length > 0) { const filterDetails = []; subscription.filters.forEach((filter, index) => { const parts = []; // Add kinds if present if (filter.kinds && Array.isArray(filter.kinds) && filter.kinds.length > 0) { parts.push(`kinds:[${filter.kinds.join(',')}]`); } // Add authors if present (truncate for display) if (filter.authors && Array.isArray(filter.authors) && filter.authors.length > 0) { const authorCount = filter.authors.length; if (authorCount === 1) { const shortPubkey = filter.authors[0].substring(0, 8) + '...'; parts.push(`authors:[${shortPubkey}]`); } else { parts.push(`authors:[${authorCount} pubkeys]`); } } // Add ids if present if (filter.ids && Array.isArray(filter.ids) && filter.ids.length > 0) { const idCount = filter.ids.length; parts.push(`ids:[${idCount} event${idCount > 1 ? 's' : ''}]`); } // Add time range if present const timeParts = []; if (filter.since && filter.since > 0) { const sinceDate = new Date(filter.since * 1000).toLocaleString(); timeParts.push(`since:${sinceDate}`); } if (filter.until && filter.until > 0) { const untilDate = new Date(filter.until * 1000).toLocaleString(); timeParts.push(`until:${untilDate}`); } if (timeParts.length > 0) { parts.push(timeParts.join(', ')); } // Add limit if present if (filter.limit && filter.limit > 0) { parts.push(`limit:${filter.limit}`); } // Add tag filters if present if (filter.tag_filters && Array.isArray(filter.tag_filters) && filter.tag_filters.length > 0) { parts.push(`tags:[${filter.tag_filters.length} filter${filter.tag_filters.length > 1 ? 's' : ''}]`); } if (parts.length > 0) { filterDetails.push(parts.join(', ')); } else { filterDetails.push('empty filter'); } }); filtersDisplay = filterDetails.join(' | '); } row.innerHTML = ` ${subscription.id || 'N/A'} ${clientIP} ${durationStr} ${subscription.events_sent || 0} ${status} ${filtersDisplay} `; tableBody.appendChild(row); }); } // Helper function to format duration in human-readable format function formatDuration(seconds) { if (seconds < 60) return `${seconds}s`; if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${seconds % 60}s`; if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`; return `${Math.floor(seconds / 86400)}d ${Math.floor((seconds % 86400) / 3600)}h`; } // Update statistics status indicator (disabled - status display removed) function updateStatsStatus(status, message = '') { // Status display has been removed from the UI return; } // Utility function to format file size function formatFileSize(bytes) { if (!bytes || bytes === 0) return '0 B'; const k = 1024; const sizes = ['B', 'KB', 'MB', 'GB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]; } // Utility function to format timestamp function formatTimestamp(timestamp) { if (!timestamp) return '-'; const date = new Date(timestamp * 1000); return date.toLocaleString(); } // Update statistics cell with flash animation if value changed function updateStatsCell(cellId, newValue) { const cell = document.getElementById(cellId); if (!cell) return; const currentValue = cell.textContent; cell.textContent = newValue; // Flash if value changed if (currentValue !== newValue && currentValue !== '-') { cell.classList.add('flash-value'); setTimeout(() => { cell.classList.remove('flash-value'); }, 500); } } // Start auto-refreshing database statistics every 10 seconds function startStatsAutoRefresh() { // DISABLED - Using real-time monitoring events instead of polling // This function is kept for backward compatibility but no longer starts auto-refresh log('Database statistics auto-refresh DISABLED - using real-time monitoring events', 'INFO'); } // Stop auto-refreshing database statistics function stopStatsAutoRefresh() { if (statsAutoRefreshInterval) { clearInterval(statsAutoRefreshInterval); statsAutoRefreshInterval = null; } if (countdownInterval) { clearInterval(countdownInterval); countdownInterval = null; } // Reset countdown display updateCountdownDisplay(); log('Database statistics auto-refresh stopped', 'INFO'); } // Update countdown display in refresh button function updateCountdownDisplay() { const refreshBtn = document.getElementById('refresh-stats-btn'); if (!refreshBtn) return; // DISABLED - No countdown display when using real-time monitoring // Show empty button text refreshBtn.textContent = ''; } // Flash refresh button red on successful refresh function flashRefreshButton() { const refreshBtn = document.getElementById('refresh-stats-btn'); if (!refreshBtn) return; // DISABLED - No flashing when using real-time monitoring // This function is kept for backward compatibility } // Event handlers for test buttons document.addEventListener('DOMContentLoaded', () => { // Test button event handlers const testGetAuthRulesBtn = document.getElementById('test-get-auth-rules-btn'); const testClearAuthRulesBtn = document.getElementById('test-clear-auth-rules-btn'); const testAddBlacklistBtn = document.getElementById('test-add-blacklist-btn'); const testAddWhitelistBtn = document.getElementById('test-add-whitelist-btn'); const testConfigQueryBtn = document.getElementById('test-config-query-btn'); const testPostEventBtn = document.getElementById('test-post-event-btn'); const clearTestLogBtn = document.getElementById('clear-test-log-btn'); const generateTestKeyBtn = document.getElementById('generate-test-key-btn'); if (testGetAuthRulesBtn) { testGetAuthRulesBtn.addEventListener('click', testGetAuthRules); } if (testClearAuthRulesBtn) { testClearAuthRulesBtn.addEventListener('click', testClearAuthRules); } if (testAddBlacklistBtn) { testAddBlacklistBtn.addEventListener('click', testAddBlacklist); } if (testAddWhitelistBtn) { testAddWhitelistBtn.addEventListener('click', testAddWhitelist); } if (testConfigQueryBtn) { testConfigQueryBtn.addEventListener('click', testConfigQuery); } if (testPostEventBtn) { testPostEventBtn.addEventListener('click', testPostEvent); } if (clearTestLogBtn) { clearTestLogBtn.addEventListener('click', () => { const testLog = document.getElementById('test-event-log'); if (testLog) { testLog.innerHTML = '
SYSTEM: Test log cleared.
'; } }); } if (generateTestKeyBtn) { generateTestKeyBtn.addEventListener('click', generateRandomTestKey); } // Show test input section when needed const testInputSection = document.getElementById('test-input-section'); if (testInputSection) { testInputSection.style.display = 'block'; } // Database statistics event handlers const refreshStatsBtn = document.getElementById('refresh-stats-btn'); if (refreshStatsBtn) { refreshStatsBtn.addEventListener('click', sendStatsQuery); } // Subscription details section is always visible when authenticated // NIP-17 DM event handlers if (sendDmBtn) { sendDmBtn.addEventListener('click', sendNIP17DM); } // SQL Query event handlers const executeSqlBtn = document.getElementById('execute-sql-btn'); const clearSqlBtn = document.getElementById('clear-sql-btn'); const clearHistoryBtn = document.getElementById('clear-history-btn'); if (executeSqlBtn) { executeSqlBtn.addEventListener('click', executeSqlQuery); } if (clearSqlBtn) { clearSqlBtn.addEventListener('click', clearSqlQuery); } if (clearHistoryBtn) { clearHistoryBtn.addEventListener('click', clearQueryHistory); } }); // Dark mode functionality function toggleDarkMode() { const body = document.body; const isDarkMode = body.classList.contains('dark-mode'); if (isDarkMode) { body.classList.remove('dark-mode'); localStorage.setItem('darkMode', 'false'); updateDarkModeButton(false); log('Switched to light mode', 'INFO'); } else { body.classList.add('dark-mode'); localStorage.setItem('darkMode', 'true'); updateDarkModeButton(true); log('Switched to dark mode', 'INFO'); } } function updateDarkModeButton(isDarkMode) { const darkModeBtn = document.getElementById('dark-mode-btn'); if (darkModeBtn) { darkModeBtn.textContent = isDarkMode ? 'LIGHT MODE' : 'DARK MODE'; } } function initializeDarkMode() { const savedDarkMode = localStorage.getItem('darkMode'); const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches; const shouldBeDark = savedDarkMode === 'true' || (savedDarkMode === null && prefersDark); if (shouldBeDark) { document.body.classList.add('dark-mode'); updateDarkModeButton(true); } else { updateDarkModeButton(false); } } // Initialize the app document.addEventListener('DOMContentLoaded', () => { console.log('C-Relay Admin API interface loaded'); // Initialize dark mode initializeDarkMode(); // Start RELAY letter animation startRelayAnimation(); // Initialize real-time event rate chart setTimeout(() => { initializeEventRateChart(); }, 1000); // Delay to ensure text_graph.js is loaded // Ensure admin sections are hidden by default on page load updateAdminSectionsVisibility(); setTimeout(() => { initializeApp(); // Enhance SimplePool for testing after initialization setTimeout(enhancePoolForTesting, 2000); }, 100); }); // ================================ // SQL QUERY FUNCTIONS // ================================ // Predefined query templates const SQL_QUERY_TEMPLATES = { recent_events: "SELECT id, pubkey, created_at, kind, substr(content, 1, 50) as content FROM events ORDER BY created_at DESC LIMIT 20", event_stats: "SELECT * FROM event_stats", subscriptions: "SELECT * FROM active_subscriptions_log ORDER BY created_at DESC", top_pubkeys: "SELECT * FROM top_pubkeys_view", event_kinds: "SELECT * FROM event_kinds_view ORDER BY count DESC", time_stats: "SELECT * FROM time_stats_view" }; // Query history management (localStorage) const QUERY_HISTORY_KEY = 'c_relay_sql_history'; const MAX_HISTORY_ITEMS = 20; // Load query history from localStorage function loadQueryHistory() { try { const history = localStorage.getItem(QUERY_HISTORY_KEY); return history ? JSON.parse(history) : []; } catch (e) { console.error('Failed to load query history:', e); return []; } } // Save query to history function saveQueryToHistory(query) { if (!query || query.trim().length === 0) return; try { let history = loadQueryHistory(); // Remove duplicate if exists history = history.filter(q => q !== query); // Add to beginning history.unshift(query); // Limit size if (history.length > MAX_HISTORY_ITEMS) { history = history.slice(0, MAX_HISTORY_ITEMS); } localStorage.setItem(QUERY_HISTORY_KEY, JSON.stringify(history)); updateQueryDropdown(); } catch (e) { console.error('Failed to save query history:', e); } } // Clear query history function clearQueryHistory() { if (confirm('Clear all query history?')) { localStorage.removeItem(QUERY_HISTORY_KEY); updateQueryDropdown(); } } // Update dropdown with history function updateQueryDropdown() { const historyGroup = document.getElementById('history-group'); if (!historyGroup) return; // Clear existing history options historyGroup.innerHTML = ''; const history = loadQueryHistory(); if (history.length === 0) { const option = document.createElement('option'); option.value = ''; option.textContent = '(no history)'; option.disabled = true; historyGroup.appendChild(option); return; } history.forEach((query, index) => { const option = document.createElement('option'); option.value = `history_${index}`; // Truncate long queries for display const displayQuery = query.length > 60 ? query.substring(0, 60) + '...' : query; option.textContent = displayQuery; option.dataset.query = query; historyGroup.appendChild(option); }); } // Load selected query from dropdown function loadSelectedQuery() { const dropdown = document.getElementById('query-dropdown'); const selectedValue = dropdown.value; if (!selectedValue) return; let query = ''; // Check if it's a template if (SQL_QUERY_TEMPLATES[selectedValue]) { query = SQL_QUERY_TEMPLATES[selectedValue]; } // Check if it's from history else if (selectedValue.startsWith('history_')) { const selectedOption = dropdown.options[dropdown.selectedIndex]; query = selectedOption.dataset.query; } if (query) { document.getElementById('sql-input').value = query; } // Reset dropdown to placeholder dropdown.value = ''; } // Clear the SQL query input function clearSqlQuery() { document.getElementById('sql-input').value = ''; document.getElementById('query-info').innerHTML = ''; document.getElementById('query-table').innerHTML = ''; } // Execute SQL query via admin API async function executeSqlQuery() { const query = document.getElementById('sql-input').value; if (!query.trim()) { log('Please enter a SQL query', 'ERROR'); document.getElementById('query-info').innerHTML = '
❌ Please enter a SQL query
'; return; } try { // Show loading state document.getElementById('query-info').innerHTML = '
Executing query...
'; document.getElementById('query-table').innerHTML = ''; // Save to history (before execution, so it's saved even if query fails) saveQueryToHistory(query.trim()); // Send query as kind 23456 admin command const command = ["sql_query", query]; const requestEvent = await sendAdminCommand(command); // Store query info for when response arrives if (requestEvent && requestEvent.id) { pendingSqlQueries.set(requestEvent.id, { query: query, timestamp: Date.now() }); } // Note: Response will be handled by the event listener // which will call displaySqlQueryResults() when response arrives } catch (error) { log('Failed to execute query: ' + error.message, 'ERROR'); document.getElementById('query-info').innerHTML = '
❌ Failed to execute query: ' + error.message + '
'; } } // Helper function to send admin commands (kind 23456 events) async function sendAdminCommand(commandArray) { if (!isLoggedIn || !userPubkey) { throw new Error('Must be logged in to send admin commands'); } if (!relayPool) { throw new Error('SimplePool connection not available'); } try { log(`Sending admin command: ${JSON.stringify(commandArray)}`, 'INFO'); // Encrypt the command array directly using NIP-44 const encrypted_content = await encryptForRelay(JSON.stringify(commandArray)); if (!encrypted_content) { throw new Error('Failed to encrypt command array'); } // Create single kind 23456 admin event const adminEvent = { kind: 23456, pubkey: userPubkey, created_at: Math.floor(Date.now() / 1000), tags: [["p", getRelayPubkey()]], content: encrypted_content }; // Sign the event const signedEvent = await window.nostr.signEvent(adminEvent); if (!signedEvent || !signedEvent.sig) { throw new Error('Event signing failed'); } // Publish via SimplePool with detailed error diagnostics const url = relayConnectionUrl.value.trim(); const publishPromises = relayPool.publish([url], signedEvent); // Use Promise.allSettled to capture per-relay outcomes const results = await Promise.allSettled(publishPromises); // Log detailed publish results for diagnostics let successCount = 0; results.forEach((result, index) => { if (result.status === 'fulfilled') { successCount++; log(`✅ Admin command published successfully to relay ${index}`, 'INFO'); } else { log(`❌ Admin command failed on relay ${index}: ${result.reason?.message || result.reason}`, 'ERROR'); } }); if (successCount === 0) { const errorDetails = results.map((r, i) => `Relay ${i}: ${r.reason?.message || r.reason}`).join('; '); throw new Error(`All relays rejected admin command event. Details: ${errorDetails}`); } log('Admin command sent successfully', 'INFO'); return signedEvent; // Return the signed event for request ID tracking } catch (error) { log(`Failed to send admin command: ${error.message}`, 'ERROR'); throw error; } } // Display SQL query results function displaySqlQueryResults(response) { const infoDiv = document.getElementById('query-info'); const tableDiv = document.getElementById('query-table'); if (response.status === 'error' || response.error) { infoDiv.innerHTML = `
❌ ${response.error || 'Query failed'}
`; tableDiv.innerHTML = ''; return; } // Show query info with request ID for debugging const rowCount = response.row_count || 0; const execTime = response.execution_time_ms || 0; const requestId = response.request_id ? response.request_id.substring(0, 8) + '...' : 'unknown'; infoDiv.innerHTML = `
✅ Query executed successfully Rows: ${rowCount} Execution Time: ${execTime}ms Request: ${requestId}
`; // Build results table if (response.rows && response.rows.length > 0) { let html = ''; response.columns.forEach(col => { html += ``; }); html += ''; response.rows.forEach(row => { html += ''; row.forEach(cell => { const cellValue = cell === null ? 'NULL' : escapeHtml(String(cell)); html += ``; }); html += ''; }); html += '
${escapeHtml(col)}
${cellValue}
'; tableDiv.innerHTML = html; } else { tableDiv.innerHTML = '

No results returned

'; } } // Handle SQL query response (called by event listener) function handleSqlQueryResponse(response) { // Check if this is a response to one of our queries if (response.request_id && pendingSqlQueries.has(response.request_id)) { const queryInfo = pendingSqlQueries.get(response.request_id); pendingSqlQueries.delete(response.request_id); // Display results displaySqlQueryResults(response); } } // Helper function to escape HTML function escapeHtml(text) { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } // Initialize query history on page load document.addEventListener('DOMContentLoaded', function() { updateQueryDropdown(); }); // RELAY letter animation function function startRelayAnimation() { const letters = document.querySelectorAll('.relay-letter'); let currentIndex = 0; function animateLetter() { // Remove underline from all letters first letters.forEach(letter => letter.classList.remove('underlined')); // Add underline to current letter if (letters[currentIndex]) { letters[currentIndex].classList.add('underlined'); } // Move to next letter currentIndex++; // If we've gone through all letters, remove all underlines and wait 4000ms then restart if (currentIndex > letters.length) { // Remove all underlines before the pause letters.forEach(letter => letter.classList.remove('underlined')); setTimeout(() => { currentIndex = 0; animateLetter(); }, 4000); } else { // Otherwise, continue to next letter after 200ms setTimeout(animateLetter, 100); } } // Start the animation animateLetter(); } // ================================ // CONFIG TOGGLE BUTTON COMPONENT // ================================ // Global registry for config toggle buttons const configToggleButtons = new Map(); // ConfigToggleButton class for tri-state boolean config toggles class ConfigToggleButton { constructor(configKey, container, options = {}) { this.configKey = configKey; this.container = container; this.state = 'false'; // Start in false state by default this.pendingValue = null; this.options = { dataType: 'boolean', category: 'monitoring', ...options }; this.render(); this.attachEventListeners(); // Register this button instance configToggleButtons.set(configKey, this); } render() { console.log('=== RENDERING CONFIG TOGGLE BUTTON ==='); console.log('Config key:', this.configKey); console.log('Container:', this.container); // Create button element this.button = document.createElement('button'); this.button.className = 'config-toggle-btn'; this.button.setAttribute('data-config-key', this.configKey); this.button.setAttribute('data-state', this.state); this.button.setAttribute('title', `Toggle ${this.configKey}`); this.updateIcon(); console.log('Button element created:', this.button); console.log('Container before append:', this.container); console.log('Container children before:', this.container.children.length); this.container.appendChild(this.button); console.log('Container children after:', this.container.children.length); console.log('Button in DOM:', document.contains(this.button)); } updateIcon() { const icons = { 'true': 'I', 'false': '0', 'indeterminate': '⟳' }; this.button.textContent = icons[this.state] || '?'; } setState(newState) { if (['true', 'false', 'indeterminate'].includes(newState)) { this.state = newState; this.button.setAttribute('data-state', newState); this.updateIcon(); } } async toggle() { console.log('=== TOGGLE BUTTON CLICKED ==='); console.log('Current state:', this.state); console.log('Button element:', this.button); if (this.state === 'indeterminate') { console.log('Ignoring toggle - currently indeterminate'); return; // Don't toggle while pending } // Toggle between true and false const newValue = this.state === 'true' ? 'false' : 'true'; this.pendingValue = newValue; console.log('Sending toggle command:', newValue); // Set to indeterminate while waiting this.setState('indeterminate'); // Create config object const configObj = { key: this.configKey, value: newValue, data_type: this.options.dataType, category: this.options.category }; console.log('Config object:', configObj); try { // Send config update command console.log('Sending config update command...'); await sendConfigUpdateCommand([configObj]); console.log('Config update command sent successfully'); log(`Config toggle sent: ${this.configKey} = ${newValue}`, 'INFO'); } catch (error) { console.log('Config update command failed:', error); log(`Failed to send config toggle: ${error.message}`, 'ERROR'); // Revert to previous state on error this.setState('false'); this.pendingValue = null; } } handleResponse(success, actualValue) { console.log('=== HANDLE RESPONSE ==='); console.log('Success:', success); console.log('Actual value:', actualValue); console.log('Pending value:', this.pendingValue); if (success) { console.log('Success - setting to actual server value:', actualValue); this.setState(actualValue); } else { console.log('Failed - reverting to false state'); // Failed - revert to false state this.setState('false'); } this.pendingValue = null; console.log('Pending value cleared'); } attachEventListeners() { this.button.addEventListener('click', () => this.toggle()); } } // Helper function to get a registered toggle button function getConfigToggleButton(configKey) { return configToggleButtons.get(configKey); } // Initialize toggle button for monitoring config function initializeMonitoringToggleButton() { console.log('=== INITIALIZING MONITORING TOGGLE BUTTON ==='); // Check if button already exists to prevent duplicates const existingButton = getConfigToggleButton('kind_34567_reporting_enabled'); if (existingButton) { console.log('Monitoring toggle button already exists, skipping creation'); return existingButton; } // Find the DATABASE STATISTICS section header const sectionHeader = document.querySelector('#databaseStatisticsSection .section-header h2'); console.log('Section header found:', sectionHeader); if (!sectionHeader) { log('Could not find DATABASE STATISTICS section header for toggle button', 'WARNING'); return; } // Create the toggle button const button = new ConfigToggleButton('kind_34567_reporting_enabled', sectionHeader.parentElement, { dataType: 'boolean', category: 'monitoring' }); console.log('Monitoring toggle button created:', button); console.log('Button element:', button.button); console.log('Button in DOM:', document.contains(button.button)); log('Monitoring toggle button initialized', 'INFO'); return button; } // Enhanced config update response handler to update toggle buttons const originalHandleConfigUpdateResponse = handleConfigUpdateResponse; handleConfigUpdateResponse = function(responseData) { console.log('=== ENHANCED CONFIG UPDATE RESPONSE HANDLER ==='); console.log('Response data:', responseData); // Call original handler originalHandleConfigUpdateResponse(responseData); // Update toggle buttons if this was a config update response if (responseData.query_type === 'config_update' && responseData.status === 'success' && responseData.processed_configs) { console.log('Processing config update response for toggle buttons'); responseData.processed_configs.forEach(config => { console.log('Processing config:', config); const button = getConfigToggleButton(config.key); console.log('Button found:', button); if (button) { const success = config.status === 'updated'; const value = String(config.value).toLowerCase(); console.log('Calling handleResponse with:', success, value); button.handleResponse(success, value); } }); } else { console.log('Not a config update response or no processed_configs'); } // Also handle config query responses to initialize toggle buttons if ((responseData.query_type === 'config_query' || responseData.query_type === 'config_all') && responseData.status === 'success' && responseData.data) { console.log('Config query response - initializing toggle buttons'); initializeToggleButtonsFromConfig(responseData); } }; // Initialize toggle button when config is loaded function initializeToggleButtonsFromConfig(configData) { console.log('=== INITIALIZING TOGGLE BUTTONS FROM CONFIG ==='); console.log('Config data:', configData); if (!configData || !configData.data) { console.log('No config data available'); return; } // Find monitoring enabled config const monitoringConfig = configData.data.find(c => c.key === 'kind_34567_reporting_enabled'); console.log('Monitoring config found:', monitoringConfig); if (monitoringConfig) { const button = getConfigToggleButton('kind_34567_reporting_enabled'); console.log('Button instance:', button); if (button) { // Convert config value to string for state setting const configValue = String(monitoringConfig.value).toLowerCase(); console.log('Setting button state to:', configValue); // Set initial state from config button.setState(configValue); log(`Monitoring toggle button set to: ${configValue}`, 'INFO'); } else { console.log('Button instance not found in registry - button should have been created on DOM ready'); } } else { console.log('Monitoring config not found in config data'); } } // Initialize toggle button after DOM is ready document.addEventListener('DOMContentLoaded', function() { console.log('=== DOM CONTENT LOADED - INITIALIZING TOGGLE BUTTON ==='); // Initialize the monitoring toggle button setTimeout(() => { console.log('=== SETTIMEOUT CALLBACK - CALLING initializeMonitoringToggleButton ==='); initializeMonitoringToggleButton(); }, 500); // Small delay to ensure DOM is fully ready });