// 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; // 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; // 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; // 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); } 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 nip17DMSection = document.getElementById('nip17DMSection'); 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 (nip17DMSection) nip17DMSection.style.display = shouldShow ? 'block' : 'none'; // Start/stop auto-refresh based on visibility if (shouldShow && databaseStatisticsSection && databaseStatisticsSection.style.display === 'block') { // Load statistics immediately, then start auto-refresh sendStatsQuery().catch(error => { console.log('Auto-fetch statistics failed: ' + error.message); }); startStatsAutoRefresh(); // 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; } await nlLite.logout(); userPubkey = null; isLoggedIn = false; currentConfig = null; // Reset relay connection state isRelayConnected = false; relayPubkey = null; // 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, and hyphens 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 ==='); 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}`); // Clean up existing pool if (relayPool) { console.log('Closing existing pool connection'); relayPool.close([url]); relayPool = null; subscriptionId = null; } // Create new SimplePool instance relayPool = new window.NostrTools.SimplePool(); 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), and kind 1059 (NIP-17 GiftWrap) 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 }, { kinds: [1059], // NIP-17 GiftWrap events "#p": [userPubkey], // Only GiftWrap events addressed to this user 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); } }, 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; 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'); } } } // 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; } // 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); 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 const subscriptionResult = await subscribeToConfiguration(); if (!subscriptionResult) { throw new Error('Failed to establish admin response subscription'); } // Wait a moment for subscription to be established 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(); }); } // 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'); } } // 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); } } // Helper function to get relay pubkey function getRelayPubkey() { // Use the dynamically fetched relay pubkey if available if (relayPubkey && isRelayConnected) { return relayPubkey; } // Fallback to hardcoded value for testing/development log('Warning: Using hardcoded relay pubkey. Please connect to relay first.', 'WARNING'); return '4f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa'; } // 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'); } } // 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); }); } // 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() { // Clear any existing interval stopStatsAutoRefresh(); // Reset countdown countdownSeconds = 10; updateCountdownDisplay(); // Start countdown interval - update every second countdownInterval = setInterval(() => { countdownSeconds--; updateCountdownDisplay(); if (countdownSeconds <= 0) { // Time to refresh if (isLoggedIn && isRelayConnected) { log('Auto-refreshing database statistics...', 'INFO'); sendStatsQuery().then(() => { // Flash button red on successful refresh flashRefreshButton(); // Reset countdown countdownSeconds = 10; updateCountdownDisplay(); }).catch(error => { log(`Auto-refresh failed: ${error.message}`, 'ERROR'); // Reset countdown even on failure countdownSeconds = 10; updateCountdownDisplay(); }); } else { // Reset countdown if not logged in/connected countdownSeconds = 10; updateCountdownDisplay(); } } }, 1000); // Update every 1 second log('Database statistics auto-refresh started (10 second intervals)', '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; if (countdownInterval && isLoggedIn && isRelayConnected) { // Japanese Kanji numbers: 〇 一 二 三 四 五 六 七 八 九 十 const kanjiNumbers = ['〇', '一', '二', '三', '四', '五', '六', '七', '八', '九', '十']; // Show single character counting down from 十 (10) to 〇 (0) if (countdownSeconds >= 0 && countdownSeconds <= 10) { refreshBtn.textContent = kanjiNumbers[countdownSeconds]; } else { refreshBtn.textContent = ''; } } else { // Show empty when not active refreshBtn.textContent = ''; } } // Flash refresh button red on successful refresh function flashRefreshButton() { const refreshBtn = document.getElementById('refresh-stats-btn'); if (!refreshBtn) return; // Add red flash class refreshBtn.classList.add('flash-red'); // Remove flash class after animation setTimeout(() => { refreshBtn.classList.remove('flash-red'); }, 500); // Match CSS animation duration } // 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); } // NIP-17 DM event handlers if (sendDmBtn) { sendDmBtn.addEventListener('click', sendNIP17DM); } }); // Initialize the app document.addEventListener('DOMContentLoaded', () => { console.log('C-Relay Admin API interface loaded'); // Ensure admin sections are hidden by default on page load updateAdminSectionsVisibility(); setTimeout(() => { initializeApp(); // Enhance SimplePool for testing after initialization setTimeout(enhancePoolForTesting, 2000); }, 100); });