Files
c-relay/api/index.js
2025-10-31 10:39:06 -04:00

5758 lines
211 KiB
JavaScript

// Global error handler to prevent page refreshes
window.addEventListener('error', function (e) {
console.error('Global error caught:', e.error);
console.error('Error message:', e.message);
console.error('Error filename:', e.filename);
console.error('Error line:', e.lineno);
e.preventDefault(); // Prevent default browser error handling
return true; // Prevent page refresh
});
window.addEventListener('unhandledrejection', function (e) {
console.error('Unhandled promise rejection:', e.reason);
e.preventDefault(); // Prevent default browser error handling
return true; // Prevent page refresh
});
// Global state
let nlLite = null;
let userPubkey = null;
let isLoggedIn = false;
let currentConfig = null;
// Global subscription state
let relayPool = null;
let subscriptionId = null;
let isSubscribed = false; // Flag to prevent multiple simultaneous subscriptions
let isSubscribing = false; // Flag to prevent re-entry during subscription setup
// 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;
// Side navigation state
let currentPage = 'statistics'; // Default page
let sideNavOpen = false;
// SQL Query state
let pendingSqlQueries = new Map();
// Real-time event rate chart
let eventRateChart = null;
let previousTotalEvents = 0; // Track previous total for rate calculation
// Relay Events state - now handled by main subscription
// 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');
// 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)
// TEMPORARILY DISABLED: Using current timestamp for debugging
function randomNow() {
// const TWO_DAYS = 2 * 24 * 60 * 60; // 172800 seconds
const now = Math.round(Date.now() / 1000);
return now; // 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) {
console.log('=== SETUP AUTOMATIC RELAY CONNECTION CALLED ===');
console.log('Call stack:', new Error().stack);
console.log('showSections:', showSections);
console.log('Current isRelayConnected:', isRelayConnected);
console.log('Current relayPool:', relayPool ? 'EXISTS' : 'NULL');
console.log('Current isSubscribed:', isSubscribed);
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
// CRITICAL: Always add trailing slash for consistent URL format
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';
}
// Set up subscription to receive admin API responses
// Note: subscribeToConfiguration() will create the SimplePool internally
await subscribeToConfiguration();
console.log('📡 Subscription established for admin API responses');
// Mark as connected
isRelayConnected = true;
// Update relay info in header
updateRelayInfoInHeader();
// Only show admin sections if explicitly requested
if (showSections) {
updateAdminSectionsVisibility();
}
console.log('✅ Automatic relay connection setup complete');
} catch (error) {
console.error('❌ Failed to setup automatic relay connection:', error);
// Still mark as connected to allow basic functionality
isRelayConnected = true;
if (showSections) {
updateAdminSectionsVisibility();
}
}
}
// Legacy function for backward compatibility
async function checkExistingAuth() {
return await checkExistingAuthWithRetries();
}
// Initialize NOSTR_LOGIN_LITE
async function initializeApp() {
try {
await window.NOSTR_LOGIN_LITE.init({
theme: 'default',
methods: {
extension: true,
local: true,
seedphrase: true,
readonly: true,
connect: true,
remote: true,
otp: false
},
floatingTab: {
enabled: false
}
});
nlLite = window.NOSTR_LOGIN_LITE;
console.log('Nostr login system initialized');
// Check for existing authentication state after initialization
const wasAlreadyLoggedIn = await checkExistingAuth();
if (wasAlreadyLoggedIn) {
console.log('User was already logged in, showing profile in header');
showProfileInHeader();
// Show admin sections since user is already authenticated and relay is connected
updateAdminSectionsVisibility();
} else {
console.log('No existing authentication found, showing login modal');
showLoginModal();
}
// Listen for authentication events
window.addEventListener('nlMethodSelected', handleAuthEvent);
window.addEventListener('nlLogout', handleLogoutEvent);
} catch (error) {
console.log('Failed to initialize Nostr login: ' + error.message);
}
}
// Handle authentication events
function handleAuthEvent(event) {
const { pubkey, method, error } = event.detail;
if (method && pubkey) {
userPubkey = pubkey;
isLoggedIn = true;
console.log(`Login successful! Method: ${method}`);
console.log(`Public key: ${pubkey}`);
// Hide login modal and show profile in header
hideLoginModal();
showProfileInHeader();
loadUserProfile();
// Automatically set up relay connection and show admin sections
setupAutomaticRelayConnection(true);
// Auto-enable monitoring when admin logs in
autoEnableMonitoring();
} else if (error) {
console.log(`Authentication error: ${error}`);
}
}
// Handle logout events
function handleLogoutEvent() {
console.log('Logout event received');
userPubkey = null;
isLoggedIn = false;
currentConfig = null;
// Reset relay connection state
isRelayConnected = false;
relayPubkey = null;
// Reset UI - hide profile and show login modal
hideProfileFromHeader();
showLoginModal();
updateConfigStatus(false);
updateAdminSectionsVisibility();
console.log('Logout event handled successfully');
}
// Update visibility of admin sections based on login and relay connection status
function updateAdminSectionsVisibility() {
const shouldShow = isLoggedIn && isRelayConnected;
// If logged in and connected, show the current page, otherwise hide all sections
if (shouldShow) {
// Show the current page
switchPage(currentPage);
// Load data for the current page
loadCurrentPageData();
} else {
// Hide all sections when not logged in or not connected
const sections = [
'databaseStatisticsSection',
'subscriptionDetailsSection',
'div_config',
'authRulesSection',
'nip17DMSection',
'sqlQuerySection'
];
sections.forEach(sectionId => {
const section = document.getElementById(sectionId);
if (section) {
section.style.display = 'none';
}
});
stopStatsAutoRefresh();
}
// Update countdown display when visibility changes
updateCountdownDisplay();
}
// Load data for the current page
function loadCurrentPageData() {
switch (currentPage) {
case 'statistics':
// Load statistics immediately (no auto-refresh - using real-time monitoring events)
sendStatsQuery().catch(error => {
console.log('Auto-fetch statistics failed: ' + error.message);
});
break;
case 'configuration':
// Load configuration
fetchConfiguration().catch(error => {
console.log('Auto-fetch configuration failed: ' + error.message);
});
break;
case 'authorization':
// Load auth rules
loadAuthRules().catch(error => {
console.log('Auto-load auth rules failed: ' + error.message);
});
break;
// Other pages don't need initial data loading
}
}
// 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';
}
}
// 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 = `<a href="https://njump.me/${npub}" target="_blank" class="npub-link">${npub}</a>`;
}
} 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'
];
// Get profile event (kind 0) for the user with timeout
const timeoutPromise = new Promise((_, reject) =>
setTimeout(() => reject(new Error('Profile query timeout')), 5000)
);
const queryPromise = profilePool.querySync(relays, {
kinds: [0],
authors: [userPubkey],
limit: 1
});
const events = await Promise.race([queryPromise, timeoutPromise]);
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
}
// Properly close the profile pool with error handling
try {
await profilePool.close(relays);
// Give time for cleanup
await new Promise(resolve => setTimeout(resolve, 100));
} catch (closeError) {
console.log('Profile pool close error (non-critical):', closeError.message);
}
} 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 = `<a href="https://njump.me/${npub}" target="_blank" class="npub-link">${npub}</a>`;
}
} 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 relay pool
if (relayPool) {
log('Closing relay pool...', 'INFO');
const url = relayConnectionUrl.value.trim();
if (url) {
try {
await relayPool.close([url]);
} catch (e) {
console.log('Pool close error (non-critical):', e.message);
}
}
relayPool = null;
subscriptionId = null;
}
// Reset subscription flags
isSubscribed = false;
isSubscribing = false;
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();
updateConfigStatus(false);
updateAdminSectionsVisibility();
log('Logged out successfully', 'INFO');
} catch (error) {
log('Logout failed: ' + error.message, 'ERROR');
}
}
function updateConfigStatus(loaded) {
if (loaded) {
configDisplay.classList.remove('hidden');
} else {
configDisplay.classList.add('hidden');
}
}
// Generate random subscription ID (avoiding colons which are rejected by relay)
function generateSubId() {
// Use only alphanumeric characters, underscores, hyphens, and commas
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-,';
let result = '';
for (let i = 0; i < 12; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length));
}
return result;
}
// WebSocket monitoring function to attach to SimplePool connections
function attachWebSocketMonitoring(relayPool, url) {
console.log('🔍 Attaching WebSocket monitoring to SimplePool...');
// SimplePool stores connections in _conn object
if (relayPool && relayPool._conn) {
// Monitor when connections are created
const originalGetConnection = relayPool._conn[url];
if (originalGetConnection) {
console.log('📡 Found existing connection for URL:', url);
// Try to access the WebSocket if it's available
const conn = relayPool._conn[url];
if (conn && conn.ws) {
attachWebSocketEventListeners(conn.ws, url);
}
}
// Override the connection getter to monitor new connections
const originalConn = relayPool._conn;
relayPool._conn = new Proxy(originalConn, {
get(target, prop) {
const conn = target[prop];
if (conn && conn.ws && !conn.ws._monitored) {
console.log('🔗 New WebSocket connection detected for:', prop);
attachWebSocketEventListeners(conn.ws, prop);
conn.ws._monitored = true;
}
return conn;
},
set(target, prop, value) {
if (value && value.ws && !value.ws._monitored) {
console.log('🔗 WebSocket connection being set for:', prop);
attachWebSocketEventListeners(value.ws, prop);
value.ws._monitored = true;
}
target[prop] = value;
return true;
}
});
}
console.log('✅ WebSocket monitoring attached');
}
function attachWebSocketEventListeners(ws, url) {
console.log(`🎯 Attaching event listeners to WebSocket for ${url}`);
// Log connection open
ws.addEventListener('open', (event) => {
console.log(`🔓 WebSocket OPEN for ${url}:`, {
readyState: ws.readyState,
url: ws.url,
protocol: ws.protocol,
extensions: ws.extensions
});
});
// Log incoming messages with full details
ws.addEventListener('message', (event) => {
try {
const data = event.data;
console.log(`📨 WebSocket MESSAGE from ${url}:`, {
type: event.type,
data: data,
dataLength: data.length,
timestamp: new Date().toISOString()
});
// Try to parse as JSON for Nostr messages
try {
const parsed = JSON.parse(data);
if (Array.isArray(parsed)) {
const [type, ...args] = parsed;
console.log(`📨 Parsed Nostr message [${type}]:`, args);
} else {
console.log(`📨 Parsed JSON:`, parsed);
}
} catch (parseError) {
console.log(`📨 Raw message (not JSON):`, data);
}
} catch (error) {
console.error(`❌ Error processing WebSocket message from ${url}:`, error);
}
});
// Log connection close with details
ws.addEventListener('close', (event) => {
console.log(`🔒 WebSocket CLOSE for ${url}:`, {
code: event.code,
reason: event.reason,
wasClean: event.wasClean,
readyState: ws.readyState,
timestamp: new Date().toISOString()
});
});
// Log errors with full details
ws.addEventListener('error', (event) => {
console.error(`❌ WebSocket ERROR for ${url}:`, {
type: event.type,
target: event.target,
readyState: ws.readyState,
url: ws.url,
timestamp: new Date().toISOString()
});
// Log additional WebSocket state
console.error(`❌ WebSocket state details:`, {
readyState: ws.readyState,
bufferedAmount: ws.bufferedAmount,
protocol: ws.protocol,
extensions: ws.extensions,
binaryType: ws.binaryType
});
});
// Override send method to log outgoing messages
const originalSend = ws.send;
ws.send = function(data) {
console.log(`📤 WebSocket SEND to ${url}:`, {
data: data,
dataLength: data.length,
readyState: ws.readyState,
timestamp: new Date().toISOString()
});
// Try to parse outgoing Nostr messages
try {
const parsed = JSON.parse(data);
if (Array.isArray(parsed)) {
const [type, ...args] = parsed;
console.log(`📤 Outgoing Nostr message [${type}]:`, args);
} else {
console.log(`📤 Outgoing JSON:`, parsed);
}
} catch (parseError) {
console.log(`📤 Outgoing raw message (not JSON):`, data);
}
return originalSend.call(this, data);
};
console.log(`✅ Event listeners attached to WebSocket for ${url}`);
}
// Configuration subscription using nostr-tools SimplePool
async function subscribeToConfiguration() {
try {
console.log('=== SUBSCRIBE TO CONFIGURATION ===');
console.log('Call stack:', new Error().stack);
// If pool already exists and subscribed, we're done
if (relayPool && isSubscribed) {
console.log('✅ Already subscribed, reusing existing pool');
return true;
}
// Prevent concurrent subscription attempts
if (isSubscribing) {
console.log('⚠️ Subscription already in progress');
return false;
}
isSubscribing = true;
const url = relayConnectionUrl.value.trim();
if (!url) {
console.error('No relay URL configured');
isSubscribing = false;
return false;
}
console.log(`🔌 Connecting to relay: ${url}`);
// Create pool ONLY if it doesn't exist
if (!relayPool) {
console.log('✨ Creating NEW SimplePool for admin operations');
relayPool = new window.NostrTools.SimplePool();
// Attach WebSocket monitoring to the new pool
attachWebSocketMonitoring(relayPool, url);
} else {
console.log('♻️ Reusing existing SimplePool');
}
subscriptionId = generateSubId();
console.log(`📝 Generated subscription ID: ${subscriptionId}`);
console.log(`👤 User pubkey: ${userPubkey}`);
console.log(`🎯 About to call relayPool.subscribeMany with URL: ${url}`);
console.log(`📊 relayPool._conn before subscribeMany:`, Object.keys(relayPool._conn || {}));
// Mark as subscribed BEFORE calling subscribeMany to prevent race conditions
isSubscribed = true;
// Subscribe to kind 23457 events (admin response events), kind 4 (NIP-04 DMs), kind 1059 (NIP-17 GiftWrap), kind 24567 (ephemeral monitoring events), and relay events (kinds 0, 10050, 10002)
console.log('🔔 Calling relayPool.subscribeMany with all filters...');
const subscription = relayPool.subscribeMany([url], [{
since: Math.floor(Date.now() / 1000) - 5, // Look back 5 seconds to avoid race condition
kinds: [23457],
authors: [getRelayPubkey()], // Only listen to responses from the relay
"#p": [userPubkey], // Only responses directed to this user
limit: 50
}, {
since: Math.floor(Date.now() / 1000),
kinds: [4], // NIP-04 Direct Messages
authors: [getRelayPubkey()], // Only listen to DMs from the relay
"#p": [userPubkey], // Only DMs directed to this user
limit: 50
}, {
since: Math.floor(Date.now() / 1000) - (2 * 24 * 60 * 60), // Look back 2 days for NIP-59 randomized timestamps
kinds: [1059], // NIP-17 GiftWrap events
"#p": [userPubkey], // Only GiftWrap events addressed to this user
limit: 50
}, {
since: Math.floor(Date.now() / 1000), // Start from current time
kinds: [24567], // Real-time ephemeral monitoring events
authors: [getRelayPubkey()], // Only listen to monitoring events from the relay
"#d": isLoggedIn ? ["event_kinds", "time_stats", "top_pubkeys", "subscription_details", "cpu_metrics"] : ["event_kinds", "time_stats", "top_pubkeys", "cpu_metrics"], // Include subscription_details only when authenticated, cpu_metrics available to all
limit: 50
}, {
since: Math.floor(Date.now() / 1000) - (24 * 60 * 60), // Look back 24 hours for relay events
kinds: [0, 10050, 10002], // Relay events: metadata, DM relays, relay list
authors: [getRelayPubkey()], // Only listen to relay's own events
limit: 10
}], {
async onevent(event) {
// Simplified logging - one line per event
if (event.kind === 24567) {
const dTag = event.tags.find(tag => tag[0] === 'd');
const dataType = dTag ? dTag[1] : 'unknown';
// console.log(`📊 Monitoring event: ${dataType}`);
} else {
console.log(`📨 Event received: kind ${event.kind}`);
}
// Handle NIP-04 DMs
if (event.kind === 4) {
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(`📨 RECEIVED KIND 1059 EVENT:`, {
id: event.id,
pubkey: event.pubkey,
created_at: event.created_at,
content: event.content.substring(0, 100) + '...',
tags: event.tags
});
try {
// Step 1: Unwrap gift wrap to get seal
const sealJson = await window.nostr.nip44.decrypt(event.pubkey, event.content);
console.log(`🔓 STEP 1 - Unwrapped gift wrap:`, sealJson.substring(0, 100) + '...');
const seal = safeJsonParse(sealJson);
if (!seal || seal.kind !== 13) {
throw new Error('Unwrapped content is not a valid seal (kind 13)');
}
console.log(`✅ Seal validated:`, { kind: seal.kind, pubkey: seal.pubkey.substring(0, 16) + '...' });
// Step 2: Unseal to get rumor
const rumorJson = await window.nostr.nip44.decrypt(seal.pubkey, seal.content);
console.log(`🔓 STEP 2 - Unsealed rumor:`, rumorJson.substring(0, 100) + '...');
const rumor = safeJsonParse(rumorJson);
if (!rumor || rumor.kind !== 14) {
throw new Error('Unsealed content is not a valid rumor (kind 14)');
}
console.log(`✅ Rumor validated:`, { kind: rumor.kind, pubkey: rumor.pubkey.substring(0, 16) + '...', content: rumor.content.substring(0, 50) + '...' });
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) {
console.error(`❌ NIP-17 DM UNWRAP FAILED:`, unwrapError);
log(`Failed to unwrap NIP-17 DM: ${unwrapError.message}`, 'ERROR');
if (typeof logTestEvent === 'function') {
logTestEvent('ERROR', `Failed to unwrap DM: ${unwrapError.message}`, 'DM');
}
}
return;
}
// Handle admin response events (kind 23457)
if (event.kind === 23457) {
// Log all received messages for testing
if (typeof logTestEvent === 'function') {
logTestEvent('RECV', `Admin response event: ${JSON.stringify(event)}`, 'EVENT');
}
// Process admin response event
processAdminResponse(event);
}
// Handle monitoring events (kind 24567 - ephemeral)
if (event.kind === 24567) {
// Process monitoring event (logging done above)
processMonitoringEvent(event);
}
// Handle relay events (kinds 0, 10050, 10002)
if ([0, 10050, 10002].includes(event.kind)) {
handleRelayEventReceived(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);
// Reset subscription state to allow re-subscription
isSubscribed = false;
isSubscribing = false;
isRelayConnected = false;
updateConfigStatus(false);
log('WebSocket connection closed - subscription state reset', 'WARNING');
}
});
// Store subscription for cleanup
relayPool.currentSubscription = subscription;
// Mark as subscribed
isSubscribed = true;
isSubscribing = false;
console.log('✅ Subscription established successfully');
return true;
} catch (error) {
console.error('Configuration subscription failed:', error.message);
console.error('Configuration subscription failed:', error);
console.error('Error stack:', error.stack);
isSubscribing = false;
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);
// Try to parse as JSON first, if it fails treat as plain text
let responseData;
try {
responseData = JSON.parse(decryptedContent);
console.log('Parsed response data:', responseData);
} catch (parseError) {
// Not JSON - treat as plain text response
console.log('Response is plain text, not JSON');
responseData = {
plain_text: true,
message: decryptedContent
};
}
// Log the response for testing
if (typeof logTestEvent === 'function') {
logTestEvent('RECV', `Decrypted response: ${JSON.stringify(responseData)}`, 'RESPONSE');
}
// Handle different types of admin responses
handleAdminResponseData(responseData);
} catch (error) {
console.error('Error processing admin response:', error);
if (typeof logTestEvent === 'function') {
logTestEvent('ERROR', `Failed to process admin response: ${error.message}`, 'ERROR');
}
}
}
// Initialize real-time event rate chart
function initializeEventRateChart() {
try {
console.log('=== INITIALIZING EVENT RATE CHART ===');
const chartContainer = document.getElementById('event-rate-chart');
console.log('Chart container found:', chartContainer);
if (!chartContainer) {
console.log('Event rate chart container not found');
return;
}
// Show immediate placeholder content
chartContainer.textContent = 'Initializing event rate chart...';
console.log('Set placeholder content');
// Check if ASCIIBarChart is available
console.log('Checking ASCIIBarChart availability...');
console.log('typeof ASCIIBarChart:', typeof ASCIIBarChart);
console.log('window.ASCIIBarChart:', window.ASCIIBarChart);
if (typeof ASCIIBarChart === 'undefined') {
console.log('ASCIIBarChart not available - text_graph.js may not be loaded');
// Show a more detailed error message
chartContainer.innerHTML = `
<div style="color: var(--accent-color); font-family: var(--font-family); padding: 10px;">
⚠️ Chart library not loaded<br>
Check: /text_graph/text_graph.js<br>
<small>Real-time event visualization unavailable</small>
</div>
`;
return;
}
// Create stub elements that the chart expects for info display
createChartStubElements();
console.log('Creating ASCIIBarChart instance...');
// Initialize the chart with correct parameters based on text_graph.js API
eventRateChart = new ASCIIBarChart('event-rate-chart', {
maxHeight: 11, // Chart height in lines
maxDataPoints: 76, // Show last 76 bins (5+ minutes of history)
title: 'New Events', // Chart title
xAxisLabel: '', // No X-axis label
yAxisLabel: '', // No Y-axis label
autoFitWidth: true, // Enable responsive font sizing
useBinMode: true, // Enable time bin aggregation
binDuration: 4000, // 4-second time bins
xAxisLabelFormat: 'elapsed', // Show elapsed time labels
debug: false // Disable debug logging
});
console.log('ASCIIBarChart instance created:', eventRateChart);
console.log('Chart container content after init:', chartContainer.textContent);
console.log('Chart container innerHTML after init:', chartContainer.innerHTML);
// Force an initial render
if (eventRateChart && typeof eventRateChart.render === 'function') {
console.log('Forcing initial render...');
eventRateChart.render();
console.log('Chart container content after render:', chartContainer.textContent);
}
console.log('Event rate chart initialized successfully');
log('Real-time event rate chart initialized', 'INFO');
} catch (error) {
console.error('Failed to initialize event rate chart:', error);
console.error('Error stack:', error.stack);
log(`Failed to initialize event rate chart: ${error.message}`, 'ERROR');
// Show detailed error message in the container
const chartContainer = document.getElementById('event-rate-chart');
if (chartContainer) {
chartContainer.innerHTML = `
<div style="color: var(--error-color, #ff6b6b); font-family: var(--font-family); padding: 10px;">
❌ Chart initialization failed<br>
<small>${error.message}</small><br>
<small>Check browser console for details</small>
</div>
`;
}
}
}
// Create stub elements that the ASCIIBarChart expects for info display
function createChartStubElements() {
const stubIds = ['values', 'max-value', 'scale', 'count'];
stubIds.forEach(id => {
if (!document.getElementById(id)) {
const stubElement = document.createElement('div');
stubElement.id = id;
stubElement.style.display = 'none'; // Hide stub elements
document.body.appendChild(stubElement);
}
});
console.log('Chart stub elements created');
}
// Handle monitoring events (kind 24567 - ephemeral)
async function processMonitoringEvent(event) {
try {
// Verify this is a kind 24567 ephemeral monitoring event
if (event.kind !== 24567) {
return;
}
// Verify the event is from the relay
const expectedRelayPubkey = getRelayPubkey();
if (event.pubkey !== expectedRelayPubkey) {
return;
}
// Check the d-tag to determine which type of monitoring event this is
const dTag = event.tags.find(tag => tag[0] === 'd');
if (!dTag) {
return;
}
// Parse the monitoring data (content is JSON, not encrypted for monitoring events)
const monitoringData = JSON.parse(event.content);
// Route to appropriate handler based on d-tag (no verbose logging)
switch (dTag[1]) {
case 'event_kinds':
updateStatsFromMonitoringEvent(monitoringData);
break;
case 'time_stats':
updateStatsFromTimeMonitoringEvent(monitoringData);
break;
case 'top_pubkeys':
updateStatsFromTopPubkeysMonitoringEvent(monitoringData);
break;
case 'subscription_details':
// Only process subscription details if user is authenticated
if (isLoggedIn) {
updateStatsFromSubscriptionDetailsMonitoringEvent(monitoringData);
// Also update the active subscriptions count from this data
if (monitoringData.data && monitoringData.data.subscriptions) {
updateStatsCell('active-subscriptions', monitoringData.data.subscriptions.length.toString());
}
}
break;
case 'cpu_metrics':
updateStatsFromCpuMonitoringEvent(monitoringData);
break;
default:
return;
}
} catch (error) {
console.error('Error processing monitoring event:', error);
log(`Failed to process monitoring event: ${error.message}`, 'ERROR');
}
}
// Handle different types of admin response data
function handleAdminResponseData(responseData) {
try {
console.log('=== HANDLING ADMIN RESPONSE DATA ===');
console.log('Response data:', responseData);
console.log('Response query_type:', responseData.query_type);
// Handle plain text responses (from create_relay_event and other commands)
if (responseData.plain_text) {
console.log('Handling plain text response');
log(responseData.message, 'INFO');
// Show the message in relay events status if we're on that page
if (currentPage === 'relay-events') {
// Try to determine which kind based on message content
if (responseData.message.includes('Kind: 0')) {
showStatus('kind0-status', responseData.message, 'success');
} else if (responseData.message.includes('Kind: 10050')) {
showStatus('kind10050-status', responseData.message, 'success');
} else if (responseData.message.includes('Kind: 10002')) {
showStatus('kind10002-status', responseData.message, 'success');
}
}
return;
}
// Handle auth query responses - updated to match backend response types
if (responseData.query_type &&
(responseData.query_type.includes('auth_rules') ||
responseData.query_type.includes('auth'))) {
console.log('Routing to auth query handler');
handleAuthQueryResponse(responseData);
return;
}
// Handle config update responses specifically
if (responseData.query_type === 'config_update') {
console.log('Routing to config update handler');
handleConfigUpdateResponse(responseData);
return;
}
// Handle config query responses - updated to match backend response types
if (responseData.query_type &&
(responseData.query_type.includes('config') ||
responseData.query_type.startsWith('config_'))) {
console.log('Routing to config query handler');
handleConfigQueryResponse(responseData);
return;
}
// Handle system command responses
if (responseData.command) {
console.log('Routing to system command handler');
handleSystemCommandResponse(responseData);
return;
}
// Handle auth rule modification responses
if (responseData.operation || responseData.rules_processed !== undefined) {
console.log('Routing to auth rule modification handler');
handleAuthRuleResponse(responseData);
return;
}
// Handle stats query responses
if (responseData.query_type === 'stats_query') {
console.log('Routing to stats query handler');
handleStatsQueryResponse(responseData);
return;
}
// Handle SQL query responses
if (responseData.query_type === 'sql_query') {
console.log('Routing to SQL query handler');
handleSqlQueryResponse(responseData);
return;
}
// Generic response handling
console.log('Using generic response handler');
if (typeof logTestEvent === 'function') {
logTestEvent('RECV', `Generic admin response: ${JSON.stringify(responseData)}`, 'RESPONSE');
}
} catch (error) {
console.error('Error handling admin response data:', error);
if (typeof logTestEvent === 'function') {
logTestEvent('ERROR', `Failed to handle response data: ${error.message}`, 'ERROR');
}
}
}
// Handle config query responses
function handleConfigQueryResponse(responseData) {
console.log('=== CONFIG QUERY RESPONSE ===');
console.log('Query type:', responseData.query_type);
console.log('Total results:', responseData.total_results);
console.log('Data:', responseData.data);
// Convert the config response data to the format expected by displayConfiguration
if (responseData.data && responseData.data.length > 0) {
console.log('Converting config response to display format...');
// Create a synthetic event structure for displayConfiguration
const syntheticEvent = {
id: 'config_response_' + Date.now(),
pubkey: getRelayPubkey(),
created_at: Math.floor(Date.now() / 1000),
kind: 'config_response',
content: 'Configuration from admin API',
tags: []
};
// Convert config data to tags format
responseData.data.forEach(config => {
const key = config.key || config.config_key;
const value = config.value || config.config_value;
if (key && value !== undefined) {
syntheticEvent.tags.push([key, value]);
}
});
console.log('Synthetic event created:', syntheticEvent);
console.log('Calling displayConfiguration with synthetic event...');
// Display the configuration using the original display function
displayConfiguration(syntheticEvent);
// Update relay info in header with config data
updateStoredRelayInfo(responseData);
// Initialize toggle buttons with config data
initializeToggleButtonsFromConfig(responseData);
log(`Configuration loaded: ${responseData.total_results} parameters`, 'INFO');
} else {
console.log('No configuration data received');
updateConfigStatus(false);
}
// Also log to test interface for debugging
if (typeof logTestEvent === 'function') {
logTestEvent('RECV', `Config query response: ${responseData.query_type}, ${responseData.total_results} results`, 'CONFIG_QUERY');
if (responseData.data && responseData.data.length > 0) {
logTestEvent('RECV', '=== CONFIGURATION VALUES ===', 'CONFIG');
responseData.data.forEach((config, index) => {
const key = config.key || config.config_key || `config_${index}`;
const value = config.value || config.config_value || 'undefined';
const category = config.category || 'general';
const dataType = config.data_type || 'string';
logTestEvent('RECV', `${key}: ${value} (${dataType}, ${category})`, 'CONFIG');
});
logTestEvent('RECV', '=== END CONFIGURATION VALUES ===', 'CONFIG');
} else {
logTestEvent('RECV', 'No configuration values found', 'CONFIG_QUERY');
}
}
}
// Handle config update responses
function handleConfigUpdateResponse(responseData) {
console.log('=== CONFIG UPDATE RESPONSE ===');
console.log('Query type:', responseData.query_type);
console.log('Status:', responseData.status);
console.log('Data:', responseData.data);
if (responseData.status === 'success') {
const updatesApplied = responseData.updates_applied || 0;
log(`Configuration updated successfully: ${updatesApplied} parameters changed`, 'INFO');
// Show success message with details
if (responseData.data && Array.isArray(responseData.data)) {
responseData.data.forEach((config, index) => {
if (config.status === 'success') {
log(`${config.key}: ${config.value} (${config.data_type})`, 'INFO');
} else {
log(`${config.key}: ${config.error || 'Failed to update'}`, 'ERROR');
}
});
}
// Configuration updated successfully - user can manually refresh using Fetch Config button
log('Configuration updated successfully. Click "Fetch Config" to refresh the display.', 'INFO');
} else {
const errorMessage = responseData.message || responseData.error || 'Unknown error';
log(`Configuration update failed: ${errorMessage}`, 'ERROR');
// Show detailed error information if available
if (responseData.data && Array.isArray(responseData.data)) {
responseData.data.forEach((config, index) => {
if (config.status === 'error') {
log(`${config.key}: ${config.error || 'Failed to update'}`, 'ERROR');
}
});
}
}
// Log to test interface for debugging
if (typeof logTestEvent === 'function') {
logTestEvent('RECV', `Config update response: ${responseData.status}`, 'CONFIG_UPDATE');
if (responseData.data && responseData.data.length > 0) {
responseData.data.forEach((config, index) => {
const status = config.status === 'success' ? '✓' : '✗';
const message = config.status === 'success' ?
`${config.key} = ${config.value}` :
`${config.key}: ${config.error || 'Failed'}`;
logTestEvent('RECV', `${status} ${message}`, 'CONFIG_UPDATE');
});
} else {
logTestEvent('RECV', 'No configuration update details received', 'CONFIG_UPDATE');
}
}
}
// Handle auth query responses
function handleAuthQueryResponse(responseData) {
console.log('=== AUTH QUERY RESPONSE ===');
console.log('Query type:', responseData.query_type);
console.log('Total results:', responseData.total_results);
console.log('Data:', responseData.data);
// Update the current auth rules with the response data
if (responseData.data && Array.isArray(responseData.data)) {
currentAuthRules = responseData.data;
console.log('Updated currentAuthRules with', currentAuthRules.length, 'rules');
// Always show the auth rules table when we receive data (no VIEW RULES button anymore)
console.log('Auto-showing auth rules table since we received data...');
showAuthRulesTable();
updateAuthRulesStatus('loaded');
log(`Loaded ${responseData.total_results} auth rules from relay`, 'INFO');
} else {
currentAuthRules = [];
console.log('No auth rules data received, cleared currentAuthRules');
// Show empty table (no VIEW RULES button anymore)
console.log('Auto-showing auth rules table with empty data...');
showAuthRulesTable();
updateAuthRulesStatus('loaded');
log('No auth rules found on relay', 'INFO');
}
if (typeof logTestEvent === 'function') {
logTestEvent('RECV', `Auth query response: ${responseData.query_type}, ${responseData.total_results} results`, 'AUTH_QUERY');
if (responseData.data && responseData.data.length > 0) {
responseData.data.forEach((rule, index) => {
logTestEvent('RECV', `Rule ${index + 1}: ${rule.rule_type} - ${rule.pattern_value || rule.rule_target}`, 'AUTH_RULE');
});
} else {
logTestEvent('RECV', 'No auth rules found', 'AUTH_QUERY');
}
}
}
// Handle system command responses
function handleSystemCommandResponse(responseData) {
console.log('=== SYSTEM COMMAND RESPONSE ===');
console.log('Command:', responseData.command);
console.log('Status:', responseData.status);
// Handle delete auth rule responses
if (responseData.command === 'delete_auth_rule') {
if (responseData.status === 'success') {
log('Auth rule deleted successfully', 'INFO');
// Refresh the auth rules display
loadAuthRules();
} else {
log(`Failed to delete auth rule: ${responseData.message || 'Unknown error'}`, 'ERROR');
}
}
// Handle clear all auth rules responses
if (responseData.command === 'clear_all_auth_rules') {
if (responseData.status === 'success') {
const rulesCleared = responseData.rules_cleared || 0;
log(`Successfully cleared ${rulesCleared} auth rules`, 'INFO');
// Clear local auth rules and refresh display
currentAuthRules = [];
displayAuthRules(currentAuthRules);
} else {
log(`Failed to clear auth rules: ${responseData.message || 'Unknown error'}`, 'ERROR');
}
}
if (typeof logTestEvent === 'function') {
logTestEvent('RECV', `System command response: ${responseData.command} - ${responseData.status}`, 'SYSTEM_CMD');
}
}
// Handle auth rule modification responses
function handleAuthRuleResponse(responseData) {
console.log('=== AUTH RULE MODIFICATION RESPONSE ===');
console.log('Operation:', responseData.operation);
console.log('Status:', responseData.status);
// Handle auth rule addition/modification responses
if (responseData.status === 'success') {
const rulesProcessed = responseData.rules_processed || 0;
log(`Successfully processed ${rulesProcessed} auth rule modifications`, 'INFO');
// Refresh the auth rules display to show the new rules
if (authRulesTableContainer && authRulesTableContainer.style.display !== 'none') {
loadAuthRules();
}
} else {
log(`Failed to process auth rule modifications: ${responseData.message || 'Unknown error'}`, 'ERROR');
}
if (typeof logTestEvent === 'function') {
logTestEvent('RECV', `Auth rule response: ${responseData.operation} - ${responseData.status}`, 'AUTH_RULE');
if (responseData.processed_rules) {
responseData.processed_rules.forEach((rule, index) => {
logTestEvent('RECV', `Processed rule ${index + 1}: ${rule.rule_type} - ${rule.pattern_value || rule.rule_target}`, 'AUTH_RULE');
});
}
}
}
// Helper function to decrypt content from relay using NIP-44
async function decryptFromRelay(encryptedContent) {
try {
console.log('Decrypting content from relay...');
// Get the relay public key for decryption
const relayPubkey = getRelayPubkey();
// Use NIP-07 extension's NIP-44 decrypt method
if (!window.nostr || !window.nostr.nip44) {
throw new Error('NIP-44 decryption not available via NIP-07 extension');
}
const decryptedContent = await window.nostr.nip44.decrypt(relayPubkey, encryptedContent);
if (!decryptedContent) {
throw new Error('NIP-44 decryption returned empty result');
}
console.log('Successfully decrypted content from relay');
return decryptedContent;
} catch (error) {
console.error('NIP-44 decryption failed:', error);
throw error;
}
}
// Fetch configuration using admin API
async function fetchConfiguration() {
try {
console.log('=== FETCHING CONFIGURATION VIA ADMIN API ===');
// Require both login and relay connection
if (!isLoggedIn || !userPubkey) {
throw new Error('Must be logged in to fetch configuration');
}
if (!isRelayConnected || !relayPubkey) {
throw new Error('Must be connected to relay to fetch configuration. Please use the Relay Connection section first.');
}
// First establish subscription to receive responses (only if not already subscribed)
const subscriptionResult = await subscribeToConfiguration();
if (!subscriptionResult) {
throw new Error('Failed to establish admin response subscription');
}
// Wait a moment for subscription to be established (only if we just created it)
if (!isSubscribed) {
await new Promise(resolve => setTimeout(resolve, 500));
}
// Send config query command if logged in
if (isLoggedIn && userPubkey && relayPool) {
console.log('Sending config query command...');
// Create command array for getting configuration
const command_array = ["config_query", "all"];
// Encrypt the command array directly using NIP-44
const encrypted_content = await encryptForRelay(JSON.stringify(command_array));
if (!encrypted_content) {
throw new Error('Failed to encrypt command array');
}
// Create single kind 23456 admin event
const configEvent = {
kind: 23456,
pubkey: userPubkey,
created_at: Math.floor(Date.now() / 1000),
tags: [["p", getRelayPubkey()]],
content: encrypted_content
};
// Sign the event
const signedEvent = await window.nostr.signEvent(configEvent);
if (!signedEvent || !signedEvent.sig) {
throw new Error('Event signing failed');
}
console.log('Config query event signed, publishing...');
// Publish via SimplePool with detailed error diagnostics
const url = relayConnectionUrl.value.trim();
const publishPromises = relayPool.publish([url], signedEvent);
// Use Promise.allSettled to capture per-relay outcomes instead of Promise.any
const results = await Promise.allSettled(publishPromises);
// Log detailed publish results for diagnostics
let successCount = 0;
results.forEach((result, index) => {
if (result.status === 'fulfilled') {
successCount++;
console.log(`✅ Relay ${index} (${url}): Event published successfully`);
if (typeof logTestEvent === 'function') {
logTestEvent('INFO', `Relay ${index} publish success`, 'PUBLISH');
}
} else {
console.error(`❌ Relay ${index} (${url}): Publish failed:`, result.reason);
if (typeof logTestEvent === 'function') {
logTestEvent('ERROR', `Relay ${index} publish failed: ${result.reason?.message || result.reason}`, 'PUBLISH');
}
}
});
// Throw error if all relays failed
if (successCount === 0) {
const errorDetails = results.map((r, i) => `Relay ${i}: ${r.reason?.message || r.reason}`).join('; ');
throw new Error(`All relays rejected the event. Details: ${errorDetails}`);
}
console.log('Config query command sent successfully - waiting for response...');
} else {
console.log('Not logged in - only subscription established for testing');
}
return true;
} catch (error) {
console.error('Failed to fetch configuration:', error);
return false;
}
}
function displayConfiguration(event) {
try {
console.log('=== DISPLAYING CONFIGURATION EVENT ===');
console.log('Event received for display:', event);
currentConfig = event;
// Clear existing table
configTableBody.innerHTML = '';
// Display tags (editable configuration parameters only)
console.log(`Processing ${event.tags.length} configuration parameters`);
event.tags.forEach((tag, index) => {
if (tag.length >= 2) {
const row = document.createElement('tr');
const key = tag[0];
const value = tag[1];
// Create editable input for value
const valueInput = document.createElement('input');
valueInput.type = 'text';
valueInput.value = value;
valueInput.className = 'config-value-input';
valueInput.dataset.key = key;
valueInput.dataset.originalValue = value;
valueInput.dataset.rowIndex = index;
// Create clickable Actions cell
const actionsCell = document.createElement('td');
actionsCell.className = 'config-actions-cell';
actionsCell.textContent = 'SAVE';
actionsCell.dataset.key = key;
actionsCell.dataset.originalValue = value;
actionsCell.dataset.rowIndex = index;
// Initially hide the SAVE text
actionsCell.style.color = 'transparent';
// Show SAVE text and make clickable when value changes
valueInput.addEventListener('input', function () {
if (this.value !== this.dataset.originalValue) {
actionsCell.style.color = 'var(--primary-color)';
actionsCell.style.cursor = 'pointer';
actionsCell.onclick = () => saveIndividualConfig(key, valueInput.value, valueInput.dataset.originalValue, actionsCell);
} else {
actionsCell.style.color = 'transparent';
actionsCell.style.cursor = 'default';
actionsCell.onclick = null;
}
});
row.innerHTML = `<td>${key}</td><td></td>`;
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 = `<td colspan="3" style="text-align: center; font-style: italic;">No configuration parameters found</td>`;
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 removed - dropdown moved to sidebar
// Logout and dark mode buttons are now in the sidebar footer
// Initialize relay pubkey container click handler for clipboard copy
const relayPubkeyContainer = document.getElementById('relay-pubkey-container');
if (relayPubkeyContainer) {
relayPubkeyContainer.addEventListener('click', async function() {
const relayPubkeyElement = document.getElementById('relay-pubkey');
if (relayPubkeyElement && relayPubkeyElement.textContent !== 'Loading...') {
try {
// Get the full npub (remove all whitespace for continuous string)
const fullNpub = relayPubkeyElement.textContent.replace(/\s/g, '');
await navigator.clipboard.writeText(fullNpub);
// Add copied class for visual feedback
relayPubkeyContainer.classList.add('copied');
// Remove the class after animation completes
setTimeout(() => {
relayPubkeyContainer.classList.remove('copied');
}, 500);
log('Relay npub copied to clipboard', 'INFO');
} catch (error) {
log('Failed to copy relay npub to clipboard', 'ERROR');
}
}
});
}
// Event handlers
fetchConfigBtn.addEventListener('click', function (e) {
e.preventDefault();
e.stopPropagation();
fetchConfiguration().catch(error => {
console.log('Manual fetch configuration failed: ' + error.message);
});
});
// ================================
// AUTH RULES MANAGEMENT FUNCTIONS
// ================================
// Global auth rules state
let currentAuthRules = [];
let editingAuthRule = null;
// DOM elements for auth rules
const authRulesSection = document.getElementById('authRulesSection');
const refreshAuthRulesBtn = document.getElementById('refreshAuthRulesBtn');
const authRulesTableContainer = document.getElementById('authRulesTableContainer');
const authRulesTableBody = document.getElementById('authRulesTableBody');
const authRuleFormContainer = document.getElementById('authRuleFormContainer');
const authRuleForm = document.getElementById('authRuleForm');
const authRuleFormTitle = document.getElementById('authRuleFormTitle');
const saveAuthRuleBtn = document.getElementById('saveAuthRuleBtn');
const cancelAuthRuleBtn = document.getElementById('cancelAuthRuleBtn');
// Show auth rules section after login
function showAuthRulesSection() {
if (authRulesSection) {
authRulesSection.style.display = 'block';
updateAuthRulesStatus('ready');
log('Auth rules section is now available', 'INFO');
}
}
// Hide auth rules section on logout
function hideAuthRulesSection() {
if (authRulesSection) {
authRulesSection.style.display = 'none';
// Add null checks for all elements
if (authRulesTableContainer) {
authRulesTableContainer.style.display = 'none';
}
if (authRuleFormContainer) {
authRuleFormContainer.style.display = 'none';
}
currentAuthRules = [];
editingAuthRule = null;
log('Auth rules section hidden', 'INFO');
}
}
// Update auth rules status indicator (removed - no status element)
function updateAuthRulesStatus(status) {
// Status element removed - no-op
}
// Load auth rules from relay using admin API
async function loadAuthRules() {
try {
log('Loading auth rules via admin API...', 'INFO');
updateAuthRulesStatus('loading');
if (!isLoggedIn || !userPubkey) {
throw new Error('Must be logged in to load auth rules');
}
if (!relayPool) {
throw new Error('SimplePool connection not available');
}
// Create command array for getting all auth rules
const command_array = ["auth_query", "all"];
// Encrypt the command array directly using NIP-44
const encrypted_content = await encryptForRelay(JSON.stringify(command_array));
if (!encrypted_content) {
throw new Error('Failed to encrypt command array');
}
// Create single kind 23456 admin event
const authEvent = {
kind: 23456,
pubkey: userPubkey,
created_at: Math.floor(Date.now() / 1000),
tags: [["p", getRelayPubkey()]],
content: encrypted_content
};
// Sign the event
const signedEvent = await window.nostr.signEvent(authEvent);
if (!signedEvent || !signedEvent.sig) {
throw new Error('Event signing failed');
}
log('Sending auth rules query to relay...', 'INFO');
// Publish via SimplePool with detailed error diagnostics
const url = relayConnectionUrl.value.trim();
const publishPromises = relayPool.publish([url], signedEvent);
// Use Promise.allSettled to capture per-relay outcomes instead of Promise.any
const results = await Promise.allSettled(publishPromises);
// Log detailed publish results for diagnostics
let successCount = 0;
results.forEach((result, index) => {
if (result.status === 'fulfilled') {
successCount++;
console.log(`✅ Auth Rules Query Relay ${index} (${url}): Event published successfully`);
if (typeof logTestEvent === 'function') {
logTestEvent('INFO', `Auth rules query relay ${index} publish success`, 'PUBLISH');
}
} else {
console.error(`❌ Auth Rules Query Relay ${index} (${url}): Publish failed:`, result.reason);
if (typeof logTestEvent === 'function') {
logTestEvent('ERROR', `Auth rules query relay ${index} publish failed: ${result.reason?.message || result.reason}`, 'PUBLISH');
}
}
});
// Throw error if all relays failed
if (successCount === 0) {
const errorDetails = results.map((r, i) => `Relay ${i}: ${r.reason?.message || r.reason}`).join('; ');
throw new Error(`All relays rejected auth rules query event. Details: ${errorDetails}`);
}
log('Auth rules query sent successfully - waiting for response...', 'INFO');
updateAuthRulesStatus('loaded');
} catch (error) {
log(`Failed to load auth rules: ${error.message}`, 'ERROR');
updateAuthRulesStatus('error');
currentAuthRules = [];
displayAuthRules(currentAuthRules);
}
}
// Display auth rules in the table
function displayAuthRules(rules) {
console.log('=== DISPLAY AUTH RULES DEBUG ===');
console.log('authRulesTableBody element:', authRulesTableBody);
console.log('Rules to display:', rules);
console.log('Rules length:', rules ? rules.length : 'undefined');
console.log('authRulesTableContainer display:', authRulesTableContainer ? authRulesTableContainer.style.display : 'element not found');
if (!authRulesTableBody) {
console.log('ERROR: authRulesTableBody element not found');
return;
}
authRulesTableBody.innerHTML = '';
console.log('Cleared existing table content');
if (!rules || rules.length === 0) {
console.log('No rules to display, showing empty message');
const row = document.createElement('tr');
row.innerHTML = `<td colspan="6" style="text-align: center; font-style: italic;">No auth rules configured</td>`;
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 = `<a href="https://njump.me/${npub}" target="_blank" class="npub-link">${npub}</a>`;
}
} catch (error) {
console.log('Failed to encode pattern_value to npub:', error.message);
}
row.innerHTML = `
<td>${rule.rule_type}</td>
<td>${rule.pattern_type || rule.operation || '-'}</td>
<td style="font-family: 'Courier New', monospace; font-size: 12px; word-break: break-all; max-width: 200px;">${patternValueLink}</td>
<td>${rule.enabled !== false ? 'Active' : 'Inactive'}</td>
<td>
<div class="inline-buttons">
<button onclick="editAuthRule(${index})" style="margin: 2px; padding: 4px 8px; font-size: 12px;">EDIT</button>
<button onclick="deleteAuthRule(${index})" style="margin: 2px; padding: 4px 8px; font-size: 12px;">DELETE</button>
</div>
</td>
`;
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');
}
}
// Monitoring is now subscription-based - no auto-enable needed
// Monitoring automatically activates when someone subscribes to kind 24567 events
async function autoEnableMonitoring() {
log('Monitoring system is subscription-based - no manual enable needed', 'INFO');
log('Subscribe to kind 24567 events to receive real-time monitoring data', 'INFO');
}
// 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 = '<div class="log-entry">No messages received yet.</div>';
}
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 = `
<span class="log-timestamp">${timestamp}</span>
<span style="color: ${directionColor}; font-weight: bold;">[${direction}]</span>
<span style="color: #666;">[${type}]</span>
${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');
}
// DEBUG: Log NIP-17 event details when created
console.log('=== NIP-17 EVENT CREATED ===');
console.log('Full event:', JSON.stringify(signedGiftWrap, null, 2));
console.log('Timestamp:', signedGiftWrap.created_at);
console.log('Local date time:', new Date(signedGiftWrap.created_at * 1000).toLocaleString());
console.log('=== END NIP-17 EVENT DEBUG ===');
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 <br> tags for proper HTML display
const formattedMessage = message.replace(/\n/g, '<br>');
// Add pubkey display for received messages
let pubkeyDisplay = '';
if (pubkey && direction === 'received') {
try {
const npub = window.NostrTools.nip19.npubEncode(pubkey);
pubkeyDisplay = ` <span style="color: #666; font-size: 11px;">(${npub})</span>`;
} catch (error) {
console.error('Failed to encode pubkey to npub:', error);
}
}
messageDiv.innerHTML = `
<span class="log-timestamp">${timestamp}</span>
<span style="color: ${directionColor}; font-weight: bold;">[${direction.toUpperCase()}]</span>
<span style="white-space: pre-wrap;">${formattedMessage}${pubkeyDisplay}</span>
`;
// Remove the "No messages received yet" placeholder if it exists
const placeholder = dmInbox.querySelector('.log-entry');
if (placeholder && placeholder.textContent === 'No messages received yet.') {
dmInbox.innerHTML = '';
}
// Add new message at the top
dmInbox.insertBefore(messageDiv, dmInbox.firstChild);
// Limit to last 50 messages
while (dmInbox.children.length > 50) {
dmInbox.removeChild(dmInbox.lastChild);
}
}
// Update relay info in header
function updateRelayInfoInHeader() {
const relayNameElement = document.getElementById('relay-name');
const relayPubkeyElement = document.getElementById('relay-pubkey');
const relayDescriptionElement = document.getElementById('relay-description');
if (!relayNameElement || !relayPubkeyElement || !relayDescriptionElement) {
return;
}
// Get relay info from NIP-11 data or use defaults
const relayInfo = getRelayInfo();
const relayName = relayInfo.name || 'C-Relay';
const relayDescription = relayInfo.description || 'Nostr Relay';
// Convert relay pubkey to npub
let relayNpub = 'Loading...';
if (relayPubkey) {
try {
relayNpub = window.NostrTools.nip19.npubEncode(relayPubkey);
} catch (error) {
console.log('Failed to encode relay pubkey to npub:', error.message);
relayNpub = relayPubkey.substring(0, 16) + '...';
}
}
// Format npub into 3 lines of 21 characters each, with spaces dividing each line into 3 groups of 7 characters
let formattedNpub = relayNpub;
if (relayNpub.length === 63) {
const line1 = relayNpub.substring(0, 7) + ' ' + relayNpub.substring(7, 14) + ' ' + relayNpub.substring(14, 21);
const line2 = relayNpub.substring(21, 28) + ' ' + relayNpub.substring(28, 35) + ' ' + relayNpub.substring(35, 42);
const line3 = relayNpub.substring(42, 49) + ' ' + relayNpub.substring(49, 56) + ' ' + relayNpub.substring(56, 63);
formattedNpub = line1 + '\n' + line2 + '\n' + line3;
}
relayNameElement.textContent = relayName;
relayPubkeyElement.textContent = formattedNpub;
relayDescriptionElement.textContent = relayDescription;
}
// Global variable to store relay info from NIP-11 or config
let relayInfoData = null;
// Helper function to get relay info from stored data
function getRelayInfo() {
// Return stored relay info if available, otherwise defaults
if (relayInfoData) {
return relayInfoData;
}
// Default values
return {
name: 'C-Relay',
description: 'Nostr Relay',
pubkey: relayPubkey
};
}
// Update stored relay info when config is loaded
function updateStoredRelayInfo(configData) {
if (configData && configData.data) {
// Extract relay info from config data
const relayName = configData.data.find(item => item.key === 'relay_name')?.value || 'C-Relay';
const relayDescription = configData.data.find(item => item.key === 'relay_description')?.value || 'Nostr Relay';
relayInfoData = {
name: relayName,
description: relayDescription,
pubkey: relayPubkey
};
// Update header immediately
updateRelayInfoInHeader();
}
}
// Helper function to get relay pubkey
function getRelayPubkey() {
// Use the dynamically fetched relay pubkey if available
if (relayPubkey) {
return relayPubkey;
}
// No fallback - throw error if relay pubkey not available
throw new Error('Relay pubkey not available. Please connect to relay first.');
}
// Enhanced SimplePool message handler to capture test responses
function enhancePoolForTesting() {
// SimplePool handles message parsing automatically, so we just need to
// ensure our event handlers log appropriately. This is already done
// in the subscription onevent callback.
console.log('SimplePool enhanced for testing - automatic message handling enabled');
}
// Generate random test pubkey function
function generateRandomTestKey() {
// Generate 32 random bytes (64 hex characters) for a valid pubkey
const randomBytes = new Uint8Array(32);
crypto.getRandomValues(randomBytes);
// Convert to hex string
const hexPubkey = Array.from(randomBytes)
.map(b => b.toString(16).padStart(2, '0'))
.join('');
// Set the generated key in the input field
const testPubkeyInput = document.getElementById('test-pubkey-input');
if (testPubkeyInput) {
testPubkeyInput.value = hexPubkey;
logTestEvent('INFO', `Generated random test pubkey: ${hexPubkey.substring(0, 16)}...`, 'KEYGEN');
}
return hexPubkey;
}
// ================================
// DATABASE STATISTICS FUNCTIONS
// ================================
// Send restart command to restart the relay using Administrator API
async function sendRestartCommand() {
if (!isLoggedIn || !userPubkey) {
log('Must be logged in to restart relay', 'ERROR');
return;
}
if (!relayPool) {
log('SimplePool connection not available', 'ERROR');
return;
}
try {
log('Sending restart command to relay...', 'INFO');
// Create command array for restart
const command_array = ["system_command", "restart"];
// Encrypt the command array directly using NIP-44
const encrypted_content = await encryptForRelay(JSON.stringify(command_array));
if (!encrypted_content) {
throw new Error('Failed to encrypt command array');
}
// Create single kind 23456 admin event
const restartEvent = {
kind: 23456,
pubkey: userPubkey,
created_at: Math.floor(Date.now() / 1000),
tags: [["p", getRelayPubkey()]],
content: encrypted_content
};
// Sign the event
const signedEvent = await window.nostr.signEvent(restartEvent);
if (!signedEvent || !signedEvent.sig) {
throw new Error('Event signing failed');
}
// Publish via SimplePool
const url = relayConnectionUrl.value.trim();
const publishPromises = relayPool.publish([url], signedEvent);
// Use Promise.allSettled to capture per-relay outcomes
const results = await Promise.allSettled(publishPromises);
// Check if any relay accepted the event
let successCount = 0;
results.forEach((result, index) => {
if (result.status === 'fulfilled') {
successCount++;
log(`Restart command published successfully to relay ${index}`, 'INFO');
} else {
log(`Restart command failed on relay ${index}: ${result.reason?.message || result.reason}`, 'ERROR');
}
});
if (successCount === 0) {
const errorDetails = results.map((r, i) => `Relay ${i}: ${r.reason?.message || r.reason}`).join('; ');
throw new Error(`All relays rejected restart command. Details: ${errorDetails}`);
}
log('Restart command sent successfully - relay should restart shortly...', 'INFO');
// Update connection status to indicate restart is in progress
updateRelayConnectionStatus('connecting');
relayConnectionStatus.textContent = 'RESTARTING...';
// The relay will disconnect and need to be reconnected after restart
// This will be handled by the WebSocket disconnection event
} catch (error) {
log(`Failed to send restart command: ${error.message}`, 'ERROR');
updateRelayConnectionStatus('error');
}
}
// Send stats_query command to get database statistics using Administrator API (inner events)
async function sendStatsQuery() {
if (!isLoggedIn || !userPubkey) {
log('Must be logged in to query database statistics', 'ERROR');
updateStatsStatus('error', 'Not logged in');
return;
}
if (!relayPool) {
log('SimplePool connection not available', 'ERROR');
updateStatsStatus('error', 'No relay connection');
return;
}
try {
updateStatsStatus('loading', 'Querying database...');
// Create command array for stats query
const command_array = ["stats_query", "all"];
// Encrypt the command array directly using NIP-44
const encrypted_content = await encryptForRelay(JSON.stringify(command_array));
if (!encrypted_content) {
throw new Error('Failed to encrypt command array');
}
// Create single kind 23456 admin event
const statsEvent = {
kind: 23456,
pubkey: userPubkey,
created_at: Math.floor(Date.now() / 1000),
tags: [["p", getRelayPubkey()]],
content: encrypted_content
};
// Sign the event
const signedEvent = await window.nostr.signEvent(statsEvent);
if (!signedEvent || !signedEvent.sig) {
throw new Error('Event signing failed');
}
log('Sending stats query command...', 'INFO');
// Publish via SimplePool
const url = relayConnectionUrl.value.trim();
const publishPromises = relayPool.publish([url], signedEvent);
// Use Promise.allSettled to capture per-relay outcomes
const results = await Promise.allSettled(publishPromises);
// Check if any relay accepted the event
let successCount = 0;
results.forEach((result, index) => {
if (result.status === 'fulfilled') {
successCount++;
log(`Stats query published successfully to relay ${index}`, 'INFO');
} else {
log(`Stats query failed on relay ${index}: ${result.reason?.message || result.reason}`, 'ERROR');
}
});
if (successCount === 0) {
const errorDetails = results.map((r, i) => `Relay ${i}: ${r.reason?.message || r.reason}`).join('; ');
throw new Error(`All relays rejected stats query event. Details: ${errorDetails}`);
}
log('Stats query command sent successfully - waiting for response...', 'INFO');
updateStatsStatus('waiting', 'Waiting for response...');
} catch (error) {
log(`Failed to send stats query: ${error.message}`, 'ERROR');
updateStatsStatus('error', error.message);
}
}
// Handle stats_query response and populate tables
function handleStatsQueryResponse(responseData) {
try {
log('Processing stats query response...', 'INFO');
console.log('Stats response data:', responseData);
if (responseData.query_type !== 'stats_query') {
log('Ignoring non-stats response', 'WARNING');
return;
}
// Populate overview table
populateStatsOverview(responseData);
// Populate event kinds table
populateStatsKinds(responseData);
// Populate time-based statistics
populateStatsTime(responseData);
// Populate top pubkeys table
populateStatsPubkeys(responseData);
updateStatsStatus('loaded');
log('Database statistics updated successfully', 'INFO');
} catch (error) {
log(`Error processing stats response: ${error.message}`, 'ERROR');
updateStatsStatus('error', 'Failed to process response');
}
}
// Update statistics display from real-time monitoring event
function updateStatsFromMonitoringEvent(monitoringData) {
try {
if (monitoringData.data_type !== 'event_kinds') {
return;
}
// Update total events count and track rate for chart
if (monitoringData.total_events !== undefined) {
const currentTotal = monitoringData.total_events;
updateStatsCell('total-events', currentTotal.toString());
// Calculate new events since last update for chart
if (previousTotalEvents > 0) {
const newEvents = currentTotal - previousTotalEvents;
if (newEvents > 0 && eventRateChart) {
console.log(`Adding ${newEvents} new events to rate chart (${currentTotal} - ${previousTotalEvents})`);
eventRateChart.addValue(newEvents);
}
}
// Update previous total for next calculation
previousTotalEvents = currentTotal;
}
// Update event kinds table with real-time data
if (monitoringData.kinds && Array.isArray(monitoringData.kinds)) {
populateStatsKindsFromMonitoring(monitoringData.kinds, monitoringData.total_events);
}
} catch (error) {
log(`Error updating stats from monitoring event: ${error.message}`, 'ERROR');
}
}
// Update statistics display from time_stats monitoring event
function updateStatsFromTimeMonitoringEvent(monitoringData) {
try {
if (monitoringData.data_type !== 'time_stats') {
return;
}
// Update time-based statistics table with real-time data
if (monitoringData.periods && Array.isArray(monitoringData.periods)) {
// Use the existing populateStatsTime function which expects the nested time_stats object
const timeStats = { last_24h: 0, last_7d: 0, last_30d: 0 };
// Extract values from periods array
monitoringData.periods.forEach(period => {
if (period.period === 'last_24h') timeStats.last_24h = period.count;
else if (period.period === 'last_7d') timeStats.last_7d = period.count;
else if (period.period === 'last_30d') timeStats.last_30d = period.count;
});
populateStatsTime({ time_stats: timeStats });
}
} catch (error) {
log(`Error updating time stats from monitoring event: ${error.message}`, 'ERROR');
}
}
// Update statistics display from top_pubkeys monitoring event
function updateStatsFromTopPubkeysMonitoringEvent(monitoringData) {
try {
if (monitoringData.data_type !== 'top_pubkeys') {
return;
}
// Update top pubkeys table with real-time data
if (monitoringData.pubkeys && Array.isArray(monitoringData.pubkeys)) {
// Pass total_events from monitoring data to the function
populateStatsPubkeysFromMonitoring(monitoringData.pubkeys, monitoringData.total_events || 0);
}
} catch (error) {
log(`Error updating top pubkeys from monitoring event: ${error.message}`, 'ERROR');
}
}
// Update statistics display from subscription_details monitoring event
function updateStatsFromSubscriptionDetailsMonitoringEvent(monitoringData) {
try {
// DEBUG: Log every subscription_details event that arrives at the webpage
// console.log('subscription_details', JSON.stringify(monitoringData, null, 2));
console.log('subscription_details decoded:', monitoringData);
if (monitoringData.data_type !== 'subscription_details') {
return;
}
// Update subscription details table with real-time data
if (monitoringData.data && Array.isArray(monitoringData.data.subscriptions)) {
populateSubscriptionDetailsTable(monitoringData.data.subscriptions);
}
} catch (error) {
log(`Error updating subscription details from monitoring event: ${error.message}`, 'ERROR');
}
}
// Update statistics display from CPU metrics monitoring event
function updateStatsFromCpuMonitoringEvent(monitoringData) {
try {
if (monitoringData.data_type !== 'cpu_metrics') {
return;
}
// Update CPU metrics in the database statistics table
if (monitoringData.process_id !== undefined) {
updateStatsCell('process-id', monitoringData.process_id.toString());
}
if (monitoringData.memory_usage_mb !== undefined) {
updateStatsCell('memory-usage', monitoringData.memory_usage_mb.toFixed(1) + ' MB');
}
if (monitoringData.current_cpu_core !== undefined) {
updateStatsCell('cpu-core', 'Core ' + monitoringData.current_cpu_core);
}
// Calculate CPU usage percentage if we have the data
if (monitoringData.process_cpu_time !== undefined && monitoringData.system_cpu_time !== undefined) {
// For now, just show the raw process CPU time (simplified)
// In a real implementation, you'd calculate deltas over time
updateStatsCell('cpu-usage', monitoringData.process_cpu_time + ' ticks');
}
} catch (error) {
log(`Error updating CPU metrics from monitoring event: ${error.message}`, 'ERROR');
}
}
// Populate event kinds table from monitoring data
function populateStatsKindsFromMonitoring(kindsData, totalEvents) {
const tableBody = document.getElementById('stats-kinds-table-body');
if (!tableBody) return;
tableBody.innerHTML = '';
if (kindsData.length === 0) {
const row = document.createElement('tr');
row.innerHTML = '<td colspan="3" style="text-align: center; font-style: italic;">No event data</td>';
tableBody.appendChild(row);
return;
}
kindsData.forEach(kind => {
const row = document.createElement('tr');
const percentage = totalEvents > 0 ? ((kind.count / totalEvents) * 100).toFixed(1) : '0.0';
row.innerHTML = `
<td>${kind.kind}</td>
<td>${kind.count}</td>
<td>${percentage}%</td>
`;
tableBody.appendChild(row);
});
}
// Populate database overview table
function populateStatsOverview(data) {
if (!data) return;
// Update individual cells with flash animation for changed values
updateStatsCell('db-size', data.database_size_bytes ? formatFileSize(data.database_size_bytes) : '-');
updateStatsCell('total-events', data.total_events || '-');
updateStatsCell('oldest-event', data.database_created_at ? formatTimestamp(data.database_created_at) : '-');
updateStatsCell('newest-event', data.latest_event_at ? formatTimestamp(data.latest_event_at) : '-');
}
// Populate event kinds distribution table
function populateStatsKinds(data) {
const tableBody = document.getElementById('stats-kinds-table-body');
if (!tableBody || !data.event_kinds) return;
tableBody.innerHTML = '';
if (data.event_kinds.length === 0) {
const row = document.createElement('tr');
row.innerHTML = '<td colspan="3" style="text-align: center; font-style: italic;">No event data</td>';
tableBody.appendChild(row);
return;
}
data.event_kinds.forEach(kind => {
const row = document.createElement('tr');
row.innerHTML = `
<td>${kind.kind}</td>
<td>${kind.count}</td>
<td>${kind.percentage}%</td>
`;
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 = '<td colspan="4" style="text-align: center; font-style: italic;">No pubkey data</td>';
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 = `<a href="https://njump.me/${npub}" target="_blank" class="npub-link">${npub}</a>`;
}
} catch (error) {
console.log('Failed to encode pubkey to npub:', error.message);
}
row.innerHTML = `
<td>${index + 1}</td>
<td style="font-family: 'Courier New', monospace; font-size: 12px; word-break: break-all;">${npubLink}</td>
<td>${pubkey.event_count}</td>
<td>${pubkey.percentage}%</td>
`;
tableBody.appendChild(row);
});
}
// Populate top pubkeys table from monitoring data
function populateStatsPubkeysFromMonitoring(pubkeysData, totalEvents) {
const tableBody = document.getElementById('stats-pubkeys-table-body');
if (!tableBody || !pubkeysData || !Array.isArray(pubkeysData)) return;
tableBody.innerHTML = '';
if (pubkeysData.length === 0) {
const row = document.createElement('tr');
row.innerHTML = '<td colspan="4" style="text-align: center; font-style: italic;">No pubkey data</td>';
tableBody.appendChild(row);
return;
}
pubkeysData.forEach((pubkey, index) => {
const row = document.createElement('tr');
// Convert hex pubkey to npub for display
let displayPubkey = pubkey.pubkey || '-';
let npubLink = displayPubkey;
try {
if (pubkey.pubkey && pubkey.pubkey.length === 64 && /^[0-9a-fA-F]+$/.test(pubkey.pubkey)) {
const npub = window.NostrTools.nip19.npubEncode(pubkey.pubkey);
displayPubkey = npub;
npubLink = `<a href="https://njump.me/${npub}" target="_blank" class="npub-link">${npub}</a>`;
}
} catch (error) {
console.log('Failed to encode pubkey to npub:', error.message);
}
// Calculate percentage using totalEvents parameter
const percentage = totalEvents > 0 ? ((pubkey.event_count / totalEvents) * 100).toFixed(1) : '0.0';
row.innerHTML = `
<td>${index + 1}</td>
<td style="font-family: 'Courier New', monospace; font-size: 12px; word-break: break-all;">${npubLink}</td>
<td>${pubkey.event_count}</td>
<td>${percentage}%</td>
`;
tableBody.appendChild(row);
});
}
// Populate subscription details table from monitoring data
function populateSubscriptionDetailsTable(subscriptionsData) {
const tableBody = document.getElementById('subscription-details-table-body');
if (!tableBody || !subscriptionsData || !Array.isArray(subscriptionsData)) return;
// Store current expand/collapse state before rebuilding
const expandedGroups = new Set();
const headerRows = tableBody.querySelectorAll('.subscription-group-header');
headerRows.forEach(header => {
const wsiPointer = header.getAttribute('data-wsi-pointer');
const isExpanded = header.getAttribute('data-expanded') === 'true';
if (isExpanded) {
expandedGroups.add(wsiPointer);
}
});
tableBody.innerHTML = '';
if (subscriptionsData.length === 0) {
const row = document.createElement('tr');
row.innerHTML = '<td colspan="4" style="text-align: center; font-style: italic;">No active subscriptions</td>';
tableBody.appendChild(row);
return;
}
// Sort subscriptions by wsi_pointer to group them together
subscriptionsData.sort((a, b) => {
const wsiA = a.wsi_pointer || '';
const wsiB = b.wsi_pointer || '';
return wsiA.localeCompare(wsiB);
});
// Group subscriptions by wsi_pointer
const groupedSubscriptions = {};
subscriptionsData.forEach(sub => {
const wsiKey = sub.wsi_pointer || 'N/A';
if (!groupedSubscriptions[wsiKey]) {
groupedSubscriptions[wsiKey] = [];
}
groupedSubscriptions[wsiKey].push(sub);
});
// Create rows for each group
Object.entries(groupedSubscriptions).forEach(([wsiPointer, subscriptions]) => {
// Calculate group summary
const subCount = subscriptions.length;
const now = Math.floor(Date.now() / 1000);
const oldestDuration = Math.max(...subscriptions.map(s => now - s.created_at));
const oldestDurationStr = formatDuration(oldestDuration);
// Create header row (summary)
const headerRow = document.createElement('tr');
headerRow.className = 'subscription-group-header';
headerRow.setAttribute('data-wsi-pointer', wsiPointer);
const wasExpanded = expandedGroups.has(wsiPointer);
headerRow.setAttribute('data-expanded', wasExpanded ? 'true' : 'false');
headerRow.innerHTML = `
<td colspan="4" style="padding: 8px;">
<span class="expand-icon" style="display: inline-block; width: 20px; transition: transform 0.2s;">▶</span>
<strong style="font-family: 'Courier New', monospace; font-size: 12px;">Websocket: ${wsiPointer}</strong>
<span style="color: #666; margin-left: 15px;">
Subscriptions: ${subCount} | Oldest: ${oldestDurationStr}
</span>
</td>
`;
// Add click handler to toggle expansion
headerRow.addEventListener('click', () => toggleSubscriptionGroup(wsiPointer));
tableBody.appendChild(headerRow);
// Create detail rows (initially hidden)
subscriptions.forEach((subscription, index) => {
const detailRow = document.createElement('tr');
detailRow.className = 'subscription-detail-row';
detailRow.setAttribute('data-wsi-group', wsiPointer);
detailRow.style.display = 'none';
// Calculate duration
const duration = now - subscription.created_at;
const durationStr = formatDuration(duration);
// Format filters
let filtersDisplay = 'None';
if (subscription.filters && subscription.filters.length > 0) {
const filterDetails = [];
subscription.filters.forEach((filter) => {
const parts = [];
if (filter.kinds && Array.isArray(filter.kinds) && filter.kinds.length > 0) {
parts.push(`kinds:[${filter.kinds.join(',')}]`);
}
if (filter.authors && Array.isArray(filter.authors) && filter.authors.length > 0) {
const authorCount = filter.authors.length;
if (authorCount === 1) {
const shortPubkey = filter.authors[0].substring(0, 8) + '...';
parts.push(`authors:[${shortPubkey}]`);
} else {
parts.push(`authors:[${authorCount} pubkeys]`);
}
}
if (filter.ids && Array.isArray(filter.ids) && filter.ids.length > 0) {
const idCount = filter.ids.length;
parts.push(`ids:[${idCount} event${idCount > 1 ? 's' : ''}]`);
}
const timeParts = [];
if (filter.since && filter.since > 0) {
const sinceDate = new Date(filter.since * 1000).toLocaleString();
timeParts.push(`since:${sinceDate}`);
}
if (filter.until && filter.until > 0) {
const untilDate = new Date(filter.until * 1000).toLocaleString();
timeParts.push(`until:${untilDate}`);
}
if (timeParts.length > 0) {
parts.push(timeParts.join(', '));
}
if (filter.limit && filter.limit > 0) {
parts.push(`limit:${filter.limit}`);
}
if (filter.tag_filters && Array.isArray(filter.tag_filters) && filter.tag_filters.length > 0) {
parts.push(`tags:[${filter.tag_filters.length} filter${filter.tag_filters.length > 1 ? 's' : ''}]`);
}
if (parts.length > 0) {
filterDetails.push(parts.join(', '));
} else {
filterDetails.push('empty filter');
}
});
filtersDisplay = filterDetails.join(' | ');
}
detailRow.innerHTML = `
<td class="subscription-detail-prefix">└─</td>
<td class="subscription-detail-id">${subscription.id || 'N/A'}</td>
<td class="subscription-detail-duration">${durationStr}</td>
<td class="subscription-detail-filters">${filtersDisplay}</td>
`;
tableBody.appendChild(detailRow);
// Restore expand/collapse state after adding all rows
if (wasExpanded) {
const detailRows = tableBody.querySelectorAll(`.subscription-detail-row[data-wsi-group="${wsiPointer}"]`);
detailRows.forEach(row => row.style.display = 'table-row');
const expandIcon = headerRow.querySelector('.expand-icon');
if (expandIcon) {
expandIcon.textContent = '▼';
expandIcon.style.transform = 'rotate(90deg)';
}
}
});
});
}
// Toggle function for expanding/collapsing groups
function toggleSubscriptionGroup(wsiPointer) {
const headerRow = document.querySelector(`.subscription-group-header[data-wsi-pointer="${wsiPointer}"]`);
const detailRows = document.querySelectorAll(`.subscription-detail-row[data-wsi-group="${wsiPointer}"]`);
const expandIcon = headerRow.querySelector('.expand-icon');
const isExpanded = headerRow.getAttribute('data-expanded') === 'true';
if (isExpanded) {
// Collapse
detailRows.forEach(row => row.style.display = 'none');
expandIcon.textContent = '▶';
expandIcon.style.transform = 'rotate(0deg)';
headerRow.setAttribute('data-expanded', 'false');
} else {
// Expand
detailRows.forEach(row => row.style.display = 'table-row');
expandIcon.textContent = '▼';
expandIcon.style.transform = 'rotate(90deg)';
headerRow.setAttribute('data-expanded', 'true');
}
}
// Helper function to format duration in human-readable format
function formatDuration(seconds) {
if (seconds < 60) return `${seconds}s`;
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`;
return `${Math.floor(seconds / 86400)}d ${Math.floor((seconds % 86400) / 3600)}h`;
}
// Update statistics status indicator (disabled - status display removed)
function updateStatsStatus(status, message = '') {
// Status display has been removed from the UI
return;
}
// Utility function to format file size
function formatFileSize(bytes) {
if (!bytes || bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
}
// Utility function to format timestamp
function formatTimestamp(timestamp) {
if (!timestamp) return '-';
const date = new Date(timestamp * 1000);
return date.toLocaleString();
}
// Update statistics cell with flash animation if value changed
function updateStatsCell(cellId, newValue) {
const cell = document.getElementById(cellId);
if (!cell) return;
const currentValue = cell.textContent;
cell.textContent = newValue;
// Flash if value changed
if (currentValue !== newValue && currentValue !== '-') {
cell.classList.add('flash-value');
setTimeout(() => {
cell.classList.remove('flash-value');
}, 500);
}
}
// Start auto-refreshing database statistics every 10 seconds
function startStatsAutoRefresh() {
// DISABLED - Using real-time monitoring events instead of polling
// This function is kept for backward compatibility but no longer starts auto-refresh
log('Database statistics auto-refresh DISABLED - using real-time monitoring events', 'INFO');
}
// Stop auto-refreshing database statistics
function stopStatsAutoRefresh() {
if (statsAutoRefreshInterval) {
clearInterval(statsAutoRefreshInterval);
statsAutoRefreshInterval = null;
}
if (countdownInterval) {
clearInterval(countdownInterval);
countdownInterval = null;
}
// Reset countdown display
updateCountdownDisplay();
log('Database statistics auto-refresh stopped', 'INFO');
}
// Update countdown display in refresh button
function updateCountdownDisplay() {
const refreshBtn = document.getElementById('refresh-stats-btn');
if (!refreshBtn) return;
// DISABLED - No countdown display when using real-time monitoring
// Show empty button text
refreshBtn.textContent = '';
}
// Flash refresh button red on successful refresh
function flashRefreshButton() {
const refreshBtn = document.getElementById('refresh-stats-btn');
if (!refreshBtn) return;
// DISABLED - No flashing when using real-time monitoring
// This function is kept for backward compatibility
}
// Event handlers for test buttons
document.addEventListener('DOMContentLoaded', () => {
// Test button event handlers
const testGetAuthRulesBtn = document.getElementById('test-get-auth-rules-btn');
const testClearAuthRulesBtn = document.getElementById('test-clear-auth-rules-btn');
const testAddBlacklistBtn = document.getElementById('test-add-blacklist-btn');
const testAddWhitelistBtn = document.getElementById('test-add-whitelist-btn');
const testConfigQueryBtn = document.getElementById('test-config-query-btn');
const testPostEventBtn = document.getElementById('test-post-event-btn');
const clearTestLogBtn = document.getElementById('clear-test-log-btn');
const generateTestKeyBtn = document.getElementById('generate-test-key-btn');
if (testGetAuthRulesBtn) {
testGetAuthRulesBtn.addEventListener('click', testGetAuthRules);
}
if (testClearAuthRulesBtn) {
testClearAuthRulesBtn.addEventListener('click', testClearAuthRules);
}
if (testAddBlacklistBtn) {
testAddBlacklistBtn.addEventListener('click', testAddBlacklist);
}
if (testAddWhitelistBtn) {
testAddWhitelistBtn.addEventListener('click', testAddWhitelist);
}
if (testConfigQueryBtn) {
testConfigQueryBtn.addEventListener('click', testConfigQuery);
}
if (testPostEventBtn) {
testPostEventBtn.addEventListener('click', testPostEvent);
}
if (clearTestLogBtn) {
clearTestLogBtn.addEventListener('click', () => {
const testLog = document.getElementById('test-event-log');
if (testLog) {
testLog.innerHTML = '<div class="log-entry"><span class="log-timestamp">SYSTEM:</span> Test log cleared.</div>';
}
});
}
if (generateTestKeyBtn) {
generateTestKeyBtn.addEventListener('click', generateRandomTestKey);
}
// Show test input section when needed
const testInputSection = document.getElementById('test-input-section');
if (testInputSection) {
testInputSection.style.display = 'block';
}
// Database statistics event handlers
const refreshStatsBtn = document.getElementById('refresh-stats-btn');
if (refreshStatsBtn) {
refreshStatsBtn.addEventListener('click', sendStatsQuery);
}
// Subscription details section is always visible when authenticated
// NIP-17 DM event handlers
if (sendDmBtn) {
sendDmBtn.addEventListener('click', sendNIP17DM);
}
// SQL Query event handlers
const executeSqlBtn = document.getElementById('execute-sql-btn');
const clearSqlBtn = document.getElementById('clear-sql-btn');
const clearHistoryBtn = document.getElementById('clear-history-btn');
if (executeSqlBtn) {
executeSqlBtn.addEventListener('click', executeSqlQuery);
}
if (clearSqlBtn) {
clearSqlBtn.addEventListener('click', clearSqlQuery);
}
if (clearHistoryBtn) {
clearHistoryBtn.addEventListener('click', clearQueryHistory);
}
});
// Dark mode functionality
function toggleDarkMode() {
const body = document.body;
const isDarkMode = body.classList.contains('dark-mode');
if (isDarkMode) {
body.classList.remove('dark-mode');
localStorage.setItem('darkMode', 'false');
updateDarkModeButton(false);
log('Switched to light mode', 'INFO');
} else {
body.classList.add('dark-mode');
localStorage.setItem('darkMode', 'true');
updateDarkModeButton(true);
log('Switched to dark mode', 'INFO');
}
}
function updateDarkModeButton(isDarkMode) {
const navDarkModeBtn = document.getElementById('nav-dark-mode-btn');
if (navDarkModeBtn) {
navDarkModeBtn.textContent = isDarkMode ? 'LIGHT MODE' : 'DARK MODE';
}
}
function initializeDarkMode() {
const savedDarkMode = localStorage.getItem('darkMode');
const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
const shouldBeDark = savedDarkMode === 'true' || (savedDarkMode === null && prefersDark);
if (shouldBeDark) {
document.body.classList.add('dark-mode');
updateDarkModeButton(true);
} else {
updateDarkModeButton(false);
}
}
// Side navigation functions
function toggleSideNav() {
const sideNav = document.getElementById('side-nav');
const overlay = document.getElementById('side-nav-overlay');
if (sideNavOpen) {
sideNav.classList.remove('open');
overlay.classList.remove('show');
sideNavOpen = false;
} else {
sideNav.classList.add('open');
overlay.classList.add('show');
sideNavOpen = true;
}
}
function closeSideNav() {
const sideNav = document.getElementById('side-nav');
const overlay = document.getElementById('side-nav-overlay');
sideNav.classList.remove('open');
overlay.classList.remove('show');
sideNavOpen = false;
}
function switchPage(pageName) {
// Update current page
currentPage = pageName;
// Update navigation active state
const navItems = document.querySelectorAll('.nav-item');
navItems.forEach(item => {
item.classList.remove('active');
if (item.getAttribute('data-page') === pageName) {
item.classList.add('active');
}
});
// Hide all sections
const sections = [
'databaseStatisticsSection',
'subscriptionDetailsSection',
'div_config',
'authRulesSection',
'relayEventsSection',
'nip17DMSection',
'sqlQuerySection'
];
sections.forEach(sectionId => {
const section = document.getElementById(sectionId);
if (section) {
section.style.display = 'none';
}
});
// Show selected section
const pageMap = {
'statistics': 'databaseStatisticsSection',
'subscriptions': 'subscriptionDetailsSection',
'configuration': 'div_config',
'authorization': 'authRulesSection',
'relay-events': 'relayEventsSection',
'dm': 'nip17DMSection',
'database': 'sqlQuerySection'
};
const targetSectionId = pageMap[pageName];
if (targetSectionId) {
const targetSection = document.getElementById(targetSectionId);
if (targetSection) {
targetSection.style.display = 'block';
}
}
// Special handling for configuration page - ensure config-display is visible and refresh data
if (pageName === 'configuration') {
const configDisplay = document.getElementById('config-display');
if (configDisplay) {
configDisplay.classList.remove('hidden');
}
// Always refresh configuration data when navigating to config page
fetchConfiguration().catch(error => {
console.log('Failed to refresh configuration on page switch: ' + error.message);
});
}
// Close side navigation
closeSideNav();
log(`Switched to page: ${pageName}`, 'INFO');
}
// Initialize the app
document.addEventListener('DOMContentLoaded', () => {
console.log('C-Relay Admin API interface loaded');
// Initialize dark mode
initializeDarkMode();
// Initialize sidebar button text
const navDarkModeBtn = document.getElementById('nav-dark-mode-btn');
if (navDarkModeBtn) {
navDarkModeBtn.textContent = document.body.classList.contains('dark-mode') ? 'LIGHT MODE' : 'DARK MODE';
}
// Start RELAY letter animation
startRelayAnimation();
// Initialize real-time event rate chart
setTimeout(() => {
initializeEventRateChart();
}, 1000); // Delay to ensure text_graph.js is loaded
// Initialize side navigation
initializeSideNavigation();
// Ensure admin sections are hidden by default on page load
updateAdminSectionsVisibility();
setTimeout(() => {
initializeApp();
// Enhance SimplePool for testing after initialization
setTimeout(enhancePoolForTesting, 2000);
}, 100);
});
// Initialize side navigation event handlers
function initializeSideNavigation() {
// Header title click handler
const headerTitle = document.getElementById('header-title');
if (headerTitle) {
headerTitle.addEventListener('click', toggleSideNav);
}
// Overlay click handler
const overlay = document.getElementById('side-nav-overlay');
if (overlay) {
overlay.addEventListener('click', closeSideNav);
}
// Navigation item click handlers
const navItems = document.querySelectorAll('.nav-item');
navItems.forEach(item => {
item.addEventListener('click', (e) => {
const pageName = e.target.getAttribute('data-page');
if (pageName) {
switchPage(pageName);
}
});
});
// Footer button handlers
const navDarkModeBtn = document.getElementById('nav-dark-mode-btn');
const navLogoutBtn = document.getElementById('nav-logout-btn');
if (navDarkModeBtn) {
navDarkModeBtn.addEventListener('click', (e) => {
e.stopPropagation();
toggleDarkMode();
// Update button text after toggle
setTimeout(() => {
navDarkModeBtn.textContent = document.body.classList.contains('dark-mode') ? 'LIGHT MODE' : 'DARK MODE';
}, 10);
closeSideNav();
});
}
if (navLogoutBtn) {
navLogoutBtn.addEventListener('click', (e) => {
e.stopPropagation();
logout();
closeSideNav();
});
}
// Set initial page
switchPage(currentPage);
}
// ================================
// SQL QUERY FUNCTIONS
// ================================
// Predefined query templates
const SQL_QUERY_TEMPLATES = {
recent_events: "SELECT id, pubkey, created_at, kind, substr(content, 1, 50) as content FROM events ORDER BY created_at DESC LIMIT 20",
event_stats: "SELECT * FROM event_stats",
subscriptions: "SELECT * FROM active_subscriptions_log ORDER BY created_at DESC",
top_pubkeys: "SELECT * FROM top_pubkeys_view",
event_kinds: "SELECT * FROM event_kinds_view ORDER BY count DESC",
time_stats: "SELECT 'total' as period, COUNT(*) as total_events, COUNT(DISTINCT pubkey) as unique_pubkeys, MIN(created_at) as oldest_event, MAX(created_at) as newest_event FROM events UNION ALL SELECT '24h' as period, COUNT(*) as total_events, COUNT(DISTINCT pubkey) as unique_pubkeys, MIN(created_at) as oldest_event, MAX(created_at) as newest_event FROM events WHERE created_at >= (strftime('%s', 'now') - 86400) UNION ALL SELECT '7d' as period, COUNT(*) as total_events, COUNT(DISTINCT pubkey) as unique_pubkeys, MIN(created_at) as oldest_event, MAX(created_at) as newest_event FROM events WHERE created_at >= (strftime('%s', 'now') - 604800) UNION ALL SELECT '30d' as period, COUNT(*) as total_events, COUNT(DISTINCT pubkey) as unique_pubkeys, MIN(created_at) as oldest_event, MAX(created_at) as newest_event FROM events WHERE created_at >= (strftime('%s', 'now') - 2592000)"
};
// Query history management (localStorage)
const QUERY_HISTORY_KEY = 'c_relay_sql_history';
const MAX_HISTORY_ITEMS = 20;
// Load query history from localStorage
function loadQueryHistory() {
try {
const history = localStorage.getItem(QUERY_HISTORY_KEY);
return history ? JSON.parse(history) : [];
} catch (e) {
console.error('Failed to load query history:', e);
return [];
}
}
// Save query to history
function saveQueryToHistory(query) {
if (!query || query.trim().length === 0) return;
try {
let history = loadQueryHistory();
// Remove duplicate if exists
history = history.filter(q => q !== query);
// Add to beginning
history.unshift(query);
// Limit size
if (history.length > MAX_HISTORY_ITEMS) {
history = history.slice(0, MAX_HISTORY_ITEMS);
}
localStorage.setItem(QUERY_HISTORY_KEY, JSON.stringify(history));
updateQueryDropdown();
} catch (e) {
console.error('Failed to save query history:', e);
}
}
// Clear query history
function clearQueryHistory() {
if (confirm('Clear all query history?')) {
localStorage.removeItem(QUERY_HISTORY_KEY);
updateQueryDropdown();
}
}
// Update dropdown with history
function updateQueryDropdown() {
const historyGroup = document.getElementById('history-group');
if (!historyGroup) return;
// Clear existing history options
historyGroup.innerHTML = '';
const history = loadQueryHistory();
if (history.length === 0) {
const option = document.createElement('option');
option.value = '';
option.textContent = '(no history)';
option.disabled = true;
historyGroup.appendChild(option);
return;
}
history.forEach((query, index) => {
const option = document.createElement('option');
option.value = `history_${index}`;
// Truncate long queries for display
const displayQuery = query.length > 60 ? query.substring(0, 60) + '...' : query;
option.textContent = displayQuery;
option.dataset.query = query;
historyGroup.appendChild(option);
});
}
// Load selected query from dropdown
function loadSelectedQuery() {
const dropdown = document.getElementById('query-dropdown');
const selectedValue = dropdown.value;
if (!selectedValue) return;
let query = '';
// Check if it's a template
if (SQL_QUERY_TEMPLATES[selectedValue]) {
query = SQL_QUERY_TEMPLATES[selectedValue];
}
// Check if it's from history
else if (selectedValue.startsWith('history_')) {
const selectedOption = dropdown.options[dropdown.selectedIndex];
query = selectedOption.dataset.query;
}
if (query) {
document.getElementById('sql-input').value = query;
}
// Reset dropdown to placeholder
dropdown.value = '';
}
// Clear the SQL query input
function clearSqlQuery() {
document.getElementById('sql-input').value = '';
document.getElementById('query-info').innerHTML = '';
document.getElementById('query-table').innerHTML = '';
}
// Execute SQL query via admin API
async function executeSqlQuery() {
const query = document.getElementById('sql-input').value;
if (!query.trim()) {
log('Please enter a SQL query', 'ERROR');
document.getElementById('query-info').innerHTML = '<div class="error-message">❌ Please enter a SQL query</div>';
return;
}
try {
// Show loading state
document.getElementById('query-info').innerHTML = '<div class="loading">Executing query...</div>';
document.getElementById('query-table').innerHTML = '';
// Save to history (before execution, so it's saved even if query fails)
saveQueryToHistory(query.trim());
// Send query as kind 23456 admin command
const command = ["sql_query", query];
const requestEvent = await sendAdminCommand(command);
// Store query info for when response arrives
if (requestEvent && requestEvent.id) {
pendingSqlQueries.set(requestEvent.id, {
query: query,
timestamp: Date.now()
});
}
// Note: Response will be handled by the event listener
// which will call displaySqlQueryResults() when response arrives
} catch (error) {
log('Failed to execute query: ' + error.message, 'ERROR');
document.getElementById('query-info').innerHTML = '<div class="error-message">❌ Failed to execute query: ' + error.message + '</div>';
}
}
// Helper function to send admin commands (kind 23456 events)
async function sendAdminCommand(commandArray) {
if (!isLoggedIn || !userPubkey) {
throw new Error('Must be logged in to send admin commands');
}
if (!relayPool) {
throw new Error('SimplePool connection not available');
}
try {
log(`Sending admin command: ${JSON.stringify(commandArray)}`, 'INFO');
// Encrypt the command array directly using NIP-44
const encrypted_content = await encryptForRelay(JSON.stringify(commandArray));
if (!encrypted_content) {
throw new Error('Failed to encrypt command array');
}
// Create single kind 23456 admin event
const adminEvent = {
kind: 23456,
pubkey: userPubkey,
created_at: Math.floor(Date.now() / 1000),
tags: [["p", getRelayPubkey()]],
content: encrypted_content
};
// Sign the event
const signedEvent = await window.nostr.signEvent(adminEvent);
if (!signedEvent || !signedEvent.sig) {
throw new Error('Event signing failed');
}
// Publish via SimplePool with detailed error diagnostics
const url = relayConnectionUrl.value.trim();
const publishPromises = relayPool.publish([url], signedEvent);
// Use Promise.allSettled to capture per-relay outcomes
const results = await Promise.allSettled(publishPromises);
// Log detailed publish results for diagnostics
let successCount = 0;
results.forEach((result, index) => {
if (result.status === 'fulfilled') {
successCount++;
log(`✅ Admin command published successfully to relay ${index}`, 'INFO');
} else {
log(`❌ Admin command failed on relay ${index}: ${result.reason?.message || result.reason}`, 'ERROR');
}
});
if (successCount === 0) {
const errorDetails = results.map((r, i) => `Relay ${i}: ${r.reason?.message || r.reason}`).join('; ');
throw new Error(`All relays rejected admin command event. Details: ${errorDetails}`);
}
log('Admin command sent successfully', 'INFO');
return signedEvent; // Return the signed event for request ID tracking
} catch (error) {
log(`Failed to send admin command: ${error.message}`, 'ERROR');
throw error;
}
}
// Display SQL query results
function displaySqlQueryResults(response) {
const infoDiv = document.getElementById('query-info');
const tableDiv = document.getElementById('query-table');
if (response.status === 'error' || response.error) {
infoDiv.innerHTML = `<div class="error-message">❌ ${response.error || 'Query failed'}</div>`;
tableDiv.innerHTML = '';
return;
}
// Show query info with request ID for debugging
const rowCount = response.row_count || 0;
const execTime = response.execution_time_ms || 0;
const requestId = response.request_id ? response.request_id.substring(0, 8) + '...' : 'unknown';
infoDiv.innerHTML = `
<div class="query-info-success">
<span>✅ Query executed successfully</span>
<span>Rows: ${rowCount}</span>
<span>Execution Time: ${execTime}ms</span>
<span class="request-id" title="${response.request_id || ''}">Request: ${requestId}</span>
</div>
`;
// Build results table
if (response.rows && response.rows.length > 0) {
let html = '<table class="sql-results-table"><thead><tr>';
response.columns.forEach(col => {
html += `<th>${escapeHtml(col)}</th>`;
});
html += '</tr></thead><tbody>';
response.rows.forEach(row => {
html += '<tr>';
row.forEach(cell => {
const cellValue = cell === null ? '<em>NULL</em>' : escapeHtml(String(cell));
html += `<td>${cellValue}</td>`;
});
html += '</tr>';
});
html += '</tbody></table>';
tableDiv.innerHTML = html;
} else {
tableDiv.innerHTML = '<p class="no-results">No results returned</p>';
}
}
// Handle SQL query response (called by event listener)
function handleSqlQueryResponse(response) {
console.log('=== HANDLING SQL QUERY RESPONSE ===');
console.log('Response:', response);
// Always display SQL query results when received
displaySqlQueryResults(response);
// Clean up any pending queries
if (response.request_id && pendingSqlQueries.has(response.request_id)) {
pendingSqlQueries.delete(response.request_id);
}
}
// Helper function to escape HTML
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Initialize query history on page load
document.addEventListener('DOMContentLoaded', function() {
updateQueryDropdown();
});
// RELAY letter animation function
function startRelayAnimation() {
const letters = document.querySelectorAll('.relay-letter');
let currentIndex = 0;
function animateLetter() {
// Remove underline from all letters first
letters.forEach(letter => letter.classList.remove('underlined'));
// Add underline to current letter
if (letters[currentIndex]) {
letters[currentIndex].classList.add('underlined');
}
// Move to next letter
currentIndex++;
// If we've gone through all letters, remove all underlines and wait 4000ms then restart
if (currentIndex > letters.length) {
// Remove all underlines before the pause
letters.forEach(letter => letter.classList.remove('underlined'));
setTimeout(() => {
currentIndex = 0;
animateLetter();
}, 4000);
} else {
// Otherwise, continue to next letter after 200ms
setTimeout(animateLetter, 100);
}
}
// Start the animation
animateLetter();
}
// ================================
// CONFIG TOGGLE BUTTON COMPONENT
// ================================
// Global registry for config toggle buttons
const configToggleButtons = new Map();
// ConfigToggleButton class for tri-state boolean config toggles
class ConfigToggleButton {
constructor(configKey, container, options = {}) {
this.configKey = configKey;
this.container = container;
this.state = 'false'; // Start in false state by default
this.pendingValue = null;
this.options = {
dataType: 'boolean',
category: 'monitoring',
...options
};
this.render();
this.attachEventListeners();
// Register this button instance
configToggleButtons.set(configKey, this);
}
render() {
console.log('=== RENDERING CONFIG TOGGLE BUTTON ===');
console.log('Config key:', this.configKey);
console.log('Container:', this.container);
// Create button element
this.button = document.createElement('button');
this.button.className = 'config-toggle-btn';
this.button.setAttribute('data-config-key', this.configKey);
this.button.setAttribute('data-state', this.state);
this.button.setAttribute('title', `Toggle ${this.configKey}`);
this.updateIcon();
console.log('Button element created:', this.button);
console.log('Container before append:', this.container);
console.log('Container children before:', this.container.children.length);
this.container.appendChild(this.button);
console.log('Container children after:', this.container.children.length);
console.log('Button in DOM:', document.contains(this.button));
}
updateIcon() {
const icons = {
'true': 'I',
'false': '0',
'indeterminate': '⟳'
};
this.button.textContent = icons[this.state] || '?';
}
setState(newState) {
if (['true', 'false', 'indeterminate'].includes(newState)) {
this.state = newState;
this.button.setAttribute('data-state', newState);
this.updateIcon();
}
}
async toggle() {
console.log('=== TOGGLE BUTTON CLICKED ===');
console.log('Current state:', this.state);
console.log('Button element:', this.button);
if (this.state === 'indeterminate') {
console.log('Ignoring toggle - currently indeterminate');
return; // Don't toggle while pending
}
// Toggle between true and false
const newValue = this.state === 'true' ? 'false' : 'true';
this.pendingValue = newValue;
console.log('Sending toggle command:', newValue);
// Set to indeterminate while waiting
this.setState('indeterminate');
// Create config object
const configObj = {
key: this.configKey,
value: newValue,
data_type: this.options.dataType,
category: this.options.category
};
console.log('Config object:', configObj);
try {
// Send config update command
console.log('Sending config update command...');
await sendConfigUpdateCommand([configObj]);
console.log('Config update command sent successfully');
log(`Config toggle sent: ${this.configKey} = ${newValue}`, 'INFO');
} catch (error) {
console.log('Config update command failed:', error);
log(`Failed to send config toggle: ${error.message}`, 'ERROR');
// Revert to previous state on error
this.setState('false');
this.pendingValue = null;
}
}
handleResponse(success, actualValue) {
console.log('=== HANDLE RESPONSE ===');
console.log('Success:', success);
console.log('Actual value:', actualValue);
console.log('Pending value:', this.pendingValue);
if (success) {
console.log('Success - setting to actual server value:', actualValue);
this.setState(actualValue);
} else {
console.log('Failed - reverting to false state');
// Failed - revert to false state
this.setState('false');
}
this.pendingValue = null;
console.log('Pending value cleared');
}
attachEventListeners() {
this.button.addEventListener('click', () => this.toggle());
}
}
// Helper function to get a registered toggle button
function getConfigToggleButton(configKey) {
return configToggleButtons.get(configKey);
}
// Monitoring is now subscription-based - no toggle button needed
// Monitoring automatically activates when someone subscribes to kind 24567 events
function initializeMonitoringToggleButton() {
console.log('=== MONITORING IS NOW SUBSCRIPTION-BASED ===');
console.log('No toggle button needed - monitoring activates automatically when subscribing to kind 24567');
log('Monitoring system is subscription-based - no manual toggle required', 'INFO');
return null;
}
// Monitoring is subscription-based - no toggle button response handling needed
const originalHandleConfigUpdateResponse = handleConfigUpdateResponse;
handleConfigUpdateResponse = function(responseData) {
console.log('=== CONFIG UPDATE RESPONSE HANDLER ===');
console.log('Response data:', responseData);
// Call original handler
originalHandleConfigUpdateResponse(responseData);
// Monitoring is now subscription-based - no toggle buttons to update
console.log('Monitoring system is subscription-based - no toggle buttons to handle');
};
// Monitoring is now subscription-based - no toggle buttons needed
function initializeToggleButtonsFromConfig(configData) {
console.log('=== MONITORING IS SUBSCRIPTION-BASED ===');
console.log('No toggle buttons needed - monitoring activates automatically when subscribing to kind 24567');
log('Monitoring system initialized - subscription-based activation ready', 'INFO');
}
// ================================
// RELAY EVENTS FUNCTIONS
// ================================
// Handle received relay events
function handleRelayEventReceived(event) {
console.log('Handling relay event:', event.kind, event);
switch (event.kind) {
case 0:
populateKind0Form(event);
break;
case 10050:
populateKind10050Form(event);
break;
case 10002:
populateKind10002Form(event);
break;
default:
console.log('Unknown relay event kind:', event.kind);
}
}
// Populate Kind 0 form (User Metadata)
function populateKind0Form(event) {
try {
const metadata = JSON.parse(event.content);
console.log('Populating Kind 0 form with:', metadata);
// Update form fields
const nameField = document.getElementById('kind0-name');
const aboutField = document.getElementById('kind0-about');
const pictureField = document.getElementById('kind0-picture');
const bannerField = document.getElementById('kind0-banner');
const nip05Field = document.getElementById('kind0-nip05');
const websiteField = document.getElementById('kind0-website');
if (nameField) nameField.value = metadata.name || '';
if (aboutField) aboutField.value = metadata.about || '';
if (pictureField) pictureField.value = metadata.picture || '';
if (bannerField) bannerField.value = metadata.banner || '';
if (nip05Field) nip05Field.value = metadata.nip05 || '';
if (websiteField) websiteField.value = metadata.website || '';
showStatus('kind0-status', 'Metadata loaded from relay', 'success');
} catch (error) {
console.error('Error populating Kind 0 form:', error);
showStatus('kind0-status', 'Error loading metadata', 'error');
}
}
// Populate Kind 10050 form (DM Relay List)
function populateKind10050Form(event) {
try {
console.log('Populating Kind 10050 form with tags:', event.tags);
// Extract relay URLs from "relay" tags
const relayUrls = event.tags
.filter(tag => tag[0] === 'relay' && tag[1])
.map(tag => tag[1]);
const relaysField = document.getElementById('kind10050-relays');
if (relaysField) {
relaysField.value = relayUrls.join('\n');
}
showStatus('kind10050-status', 'DM relay list loaded from relay', 'success');
} catch (error) {
console.error('Error populating Kind 10050 form:', error);
showStatus('kind10050-status', 'Error loading DM relay list', 'error');
}
}
// Populate Kind 10002 form (Relay List)
function populateKind10002Form(event) {
try {
console.log('Populating Kind 10002 form with tags:', event.tags);
// Clear existing entries
const container = document.getElementById('kind10002-relay-entries');
if (container) {
container.innerHTML = '';
}
// Extract relay entries from "r" tags
event.tags.forEach(tag => {
if (tag[0] === 'r' && tag[1]) {
const url = tag[1];
const marker = tag[2] || 'read'; // Default to read if no marker
const read = marker.includes('read');
const write = marker.includes('write');
addRelayEntry(url, read, write);
}
});
showStatus('kind10002-status', 'Relay list loaded from relay', 'success');
} catch (error) {
console.error('Error populating Kind 10002 form:', error);
showStatus('kind10002-status', 'Error loading relay list', 'error');
}
}
// Submit Kind 0 event
async function submitKind0Event() {
try {
showStatus('kind0-status', 'Submitting metadata...', 'info');
// Collect form data
const metadata = {
name: document.getElementById('kind0-name').value.trim(),
about: document.getElementById('kind0-about').value.trim(),
picture: document.getElementById('kind0-picture').value.trim(),
banner: document.getElementById('kind0-banner').value.trim(),
nip05: document.getElementById('kind0-nip05').value.trim(),
website: document.getElementById('kind0-website').value.trim()
};
// Remove empty fields
Object.keys(metadata).forEach(key => {
if (!metadata[key]) delete metadata[key];
});
// Validate required fields
if (!metadata.name) {
showStatus('kind0-status', 'Name is required', 'error');
return;
}
await sendCreateRelayEventCommand(0, metadata);
showStatus('kind0-status', 'Metadata updated successfully', 'success');
} catch (error) {
console.error('Error submitting Kind 0 event:', error);
showStatus('kind0-status', 'Error updating metadata: ' + error.message, 'error');
}
}
// Submit Kind 10050 event
async function submitKind10050Event() {
try {
showStatus('kind10050-status', 'Submitting DM relay list...', 'info');
// Parse textarea content
const relaysText = document.getElementById('kind10050-relays').value.trim();
const relays = relaysText.split('\n')
.map(url => url.trim())
.filter(url => url.length > 0)
.filter(url => isValidRelayUrl(url));
if (relays.length === 0) {
showStatus('kind10050-status', 'At least one valid relay URL is required', 'error');
return;
}
await sendCreateRelayEventCommand(10050, { relays });
showStatus('kind10050-status', 'DM relay list updated successfully', 'success');
} catch (error) {
console.error('Error submitting Kind 10050 event:', error);
showStatus('kind10050-status', 'Error updating DM relay list: ' + error.message, 'error');
}
}
// Submit Kind 10002 event
async function submitKind10002Event() {
try {
showStatus('kind10002-status', 'Submitting relay list...', 'info');
// Collect relay entries
const relays = [];
const entries = document.querySelectorAll('.relay-entry');
entries.forEach(entry => {
const url = entry.querySelector('.relay-url').value.trim();
const read = entry.querySelector('.relay-read').checked;
const write = entry.querySelector('.relay-write').checked;
if (url && isValidRelayUrl(url)) {
relays.push({
url: url,
read: read,
write: write
});
}
});
if (relays.length === 0) {
showStatus('kind10002-status', 'At least one valid relay entry is required', 'error');
return;
}
await sendCreateRelayEventCommand(10002, { relays });
showStatus('kind10002-status', 'Relay list updated successfully', 'success');
} catch (error) {
console.error('Error submitting Kind 10002 event:', error);
showStatus('kind10002-status', 'Error updating relay list: ' + error.message, 'error');
}
}
// Send create_relay_event command
async function sendCreateRelayEventCommand(kind, eventData) {
if (!isLoggedIn || !userPubkey) {
throw new Error('Must be logged in to create relay events');
}
if (!relayPool) {
throw new Error('SimplePool connection not available');
}
try {
console.log(`Sending create_relay_event command for kind ${kind}...`);
// Create command array
const command_array = ["create_relay_event", kind, eventData];
// Encrypt the command array
const encrypted_content = await encryptForRelay(JSON.stringify(command_array));
if (!encrypted_content) {
throw new Error('Failed to encrypt command array');
}
// Create kind 23456 admin event
const adminEvent = {
kind: 23456,
pubkey: userPubkey,
created_at: Math.floor(Date.now() / 1000),
tags: [["p", getRelayPubkey()]],
content: encrypted_content
};
// Sign the event
const signedEvent = await window.nostr.signEvent(adminEvent);
if (!signedEvent || !signedEvent.sig) {
throw new Error('Event signing failed');
}
// Publish via SimplePool
const url = relayConnectionUrl.value.trim();
const publishPromises = relayPool.publish([url], signedEvent);
// Wait for publish results
const results = await Promise.allSettled(publishPromises);
let successCount = 0;
results.forEach((result, index) => {
if (result.status === 'fulfilled') {
successCount++;
console.log(`✅ Relay event published successfully to relay ${index}`);
} else {
console.error(`❌ Relay event failed on relay ${index}:`, result.reason);
}
});
if (successCount === 0) {
const errorDetails = results.map((r, i) => `Relay ${i}: ${r.reason?.message || r.reason}`).join('; ');
throw new Error(`All relays rejected relay event. Details: ${errorDetails}`);
}
console.log(`Relay event command sent successfully for kind ${kind}`);
} catch (error) {
console.error(`Failed to send create_relay_event command for kind ${kind}:`, error);
throw error;
}
}
// Validation helpers
function isValidUrl(url) {
try {
new URL(url);
return true;
} catch {
return false;
}
}
function isValidRelayUrl(url) {
if (!isValidUrl(url)) return false;
return url.startsWith('ws://') || url.startsWith('wss://');
}
// UI helpers
function showStatus(elementId, message, type = 'info') {
const element = document.getElementById(elementId);
if (!element) return;
// Remove emojis from message
const cleanMessage = message.replace(/[\u{1F600}-\u{1F64F}]|[\u{1F300}-\u{1F5FF}]|[\u{1F680}-\u{1F6FF}]|[\u{1F1E0}-\u{1F1FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]/gu, '');
element.textContent = cleanMessage;
element.className = 'status-message';
element.style.display = 'block'; // Ensure it's visible
// Add type-specific styling
switch (type) {
case 'success':
element.style.color = 'var(--accent-color)';
break;
case 'error':
element.style.color = '#ff0000';
break;
case 'info':
default:
element.style.color = 'var(--primary-color)';
break;
}
// Auto-hide after 5 seconds
setTimeout(() => {
element.style.display = 'none';
}, 5000);
}
function addRelayEntry(url = '', read = true, write = true) {
const container = document.getElementById('kind10002-relay-entries');
if (!container) return;
const entryDiv = document.createElement('div');
entryDiv.className = 'relay-entry';
entryDiv.innerHTML = `
<div class="form-group" style="display: flex; align-items: center; gap: 10px; margin-bottom: 10px;">
<input type="url" class="relay-url" placeholder="wss://relay.example.com" value="${url}" style="flex: 1; min-width: 300px; pointer-events: auto; cursor: text;">
<label style="display: flex; align-items: center; gap: 5px; white-space: nowrap;">
<input type="checkbox" class="relay-read" ${read ? 'checked' : ''}>
Read
</label>
<label style="display: flex; align-items: center; gap: 5px; white-space: nowrap;">
<input type="checkbox" class="relay-write" ${write ? 'checked' : ''}>
Write
</label>
<button type="button" onclick="removeRelayEntry(this)" style="padding: 4px 8px; font-size: 12px; white-space: nowrap;">Remove</button>
</div>
`;
container.appendChild(entryDiv);
}
function removeRelayEntry(button) {
const entry = button.closest('.relay-entry');
if (entry) {
entry.remove();
}
}
// Initialize toggle button after DOM is ready
document.addEventListener('DOMContentLoaded', function() {
console.log('=== DOM CONTENT LOADED - INITIALIZING TOGGLE BUTTON ===');
// Initialize the monitoring toggle button
setTimeout(() => {
console.log('=== SETTIMEOUT CALLBACK - CALLING initializeMonitoringToggleButton ===');
initializeMonitoringToggleButton();
}, 500); // Small delay to ensure DOM is fully ready
// Initialize relay events functionality
initializeRelayEvents();
});
// Initialize relay events functionality
function initializeRelayEvents() {
console.log('Initializing relay events functionality...');
// Set up event handlers for relay events page
const submitKind0Btn = document.getElementById('submit-kind0-btn');
const submitKind10050Btn = document.getElementById('submit-kind10050-btn');
const submitKind10002Btn = document.getElementById('submit-kind10002-btn');
const addRelayEntryBtn = document.getElementById('add-relay-entry-btn');
if (submitKind0Btn) {
submitKind0Btn.addEventListener('click', submitKind0Event);
}
if (submitKind10050Btn) {
submitKind10050Btn.addEventListener('click', submitKind10050Event);
}
if (submitKind10002Btn) {
submitKind10002Btn.addEventListener('click', submitKind10002Event);
}
if (addRelayEntryBtn) {
addRelayEntryBtn.addEventListener('click', () => addRelayEntry());
}
// Add one empty relay entry by default for Kind 10002
const kind10002Container = document.getElementById('kind10002-relay-entries');
if (kind10002Container && kind10002Container.children.length === 0) {
addRelayEntry(); // Add one empty entry to start
console.log('Added initial empty relay entry for Kind 10002');
}
console.log('Relay events functionality initialized');
}