Files
c-relay/api/index.js

3921 lines
144 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Global error handler to prevent page refreshes
window.addEventListener('error', function (e) {
console.error('Global error caught:', e.error);
console.error('Error message:', e.message);
console.error('Error filename:', e.filename);
console.error('Error line:', e.lineno);
e.preventDefault(); // Prevent default browser error handling
return true; // Prevent page refresh
});
window.addEventListener('unhandledrejection', function (e) {
console.error('Unhandled promise rejection:', e.reason);
e.preventDefault(); // Prevent default browser error handling
return true; // Prevent page refresh
});
// Global state
let nlLite = null;
let userPubkey = null;
let isLoggedIn = false;
let currentConfig = null;
// Global subscription state
let relayPool = null;
let subscriptionId = null;
let isSubscribed = false; // Flag to prevent multiple simultaneous subscriptions
// Relay connection state
let relayInfo = null;
let isRelayConnected = false;
let relayPubkey = null;
// Simple relay URL object (replaces DOM element)
let relayConnectionUrl = { value: '' };
// Database statistics auto-refresh
let statsAutoRefreshInterval = null;
let countdownInterval = null;
let countdownSeconds = 10;
// DOM elements
const loginModal = document.getElementById('login-modal');
const loginModalContainer = document.getElementById('login-modal-container');
const profileArea = document.getElementById('profile-area');
const headerUserImage = document.getElementById('header-user-image');
const headerUserName = document.getElementById('header-user-name');
const logoutDropdown = document.getElementById('logout-dropdown');
const logoutBtn = document.getElementById('logout-btn');
// Legacy elements (kept for backward compatibility)
const persistentUserName = document.getElementById('persistent-user-name');
const persistentUserPubkey = document.getElementById('persistent-user-pubkey');
const persistentUserAbout = document.getElementById('persistent-user-about');
const persistentUserDetails = document.getElementById('persistent-user-details');
const fetchConfigBtn = document.getElementById('fetch-config-btn');
const configDisplay = document.getElementById('config-display');
const configTableBody = document.getElementById('config-table-body');
// NIP-17 DM elements
const dmOutbox = document.getElementById('dm-outbox');
const dmInbox = document.getElementById('dm-inbox');
const sendDmBtn = document.getElementById('send-dm-btn');
// Utility functions
function log(message, type = 'INFO') {
const timestamp = new Date().toISOString().split('T')[1].split('.')[0];
const logMessage = `${timestamp} [${type}]: ${message}`;
// Always log to browser console so we don't lose logs on refresh
console.log(logMessage);
// UI logging removed - using console only
}
// Utility functions
function log(message, type = 'INFO') {
const timestamp = new Date().toISOString().split('T')[1].split('.')[0];
const logMessage = `${timestamp} [${type}]: ${message}`;
// Always log to browser console so we don't lose logs on refresh
console.log(logMessage);
// UI logging removed - using console only
}
// NIP-59 helper: randomize created_at to thwart time-analysis (past 2 days)
function randomNow() {
const TWO_DAYS = 2 * 24 * 60 * 60; // 172800 seconds
const now = Math.round(Date.now() / 1000);
return Math.round(now - Math.random() * TWO_DAYS);
}
// Safe JSON parse with error handling
function safeJsonParse(jsonString) {
try {
return JSON.parse(jsonString);
} catch (error) {
console.error('JSON parse error:', error);
return null;
}
}
// ================================
// NIP-11 RELAY CONNECTION FUNCTIONS
// ================================
// Convert WebSocket URL to HTTP URL for NIP-11
function wsToHttpUrl(wsUrl) {
if (wsUrl.startsWith('ws://')) {
return wsUrl.replace('ws://', 'http://');
} else if (wsUrl.startsWith('wss://')) {
return wsUrl.replace('wss://', 'https://');
}
return wsUrl;
}
// Fetch relay information using NIP-11
async function fetchRelayInfo(relayUrl) {
try {
log(`Fetching NIP-11 relay info from: ${relayUrl}`, 'INFO');
// Convert WebSocket URL to HTTP URL
const httpUrl = wsToHttpUrl(relayUrl);
// Make HTTP request with NIP-11 headers
const response = await fetch(httpUrl, {
method: 'GET',
headers: {
'Accept': 'application/nostr+json',
'User-Agent': 'C-Relay-Admin-API/1.0'
},
timeout: 10000 // 10 second timeout
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const contentType = response.headers.get('content-type');
if (!contentType || !contentType.includes('application/nostr+json')) {
throw new Error(`Invalid content type: ${contentType}. Expected application/nostr+json`);
}
const relayInfo = await response.json();
// Log if relay info is empty (not configured yet) but don't throw error
if (!relayInfo || Object.keys(relayInfo).length === 0) {
log('Relay returned empty NIP-11 info - relay not configured yet, will use manual pubkey if provided', 'INFO');
// Return empty object - this is valid, caller will handle manual pubkey fallback
return {};
}
// Validate pubkey if present
if (relayInfo.pubkey && !/^[0-9a-fA-F]{64}$/.test(relayInfo.pubkey)) {
throw new Error(`Invalid relay pubkey format: ${relayInfo.pubkey}`);
}
log(`Successfully fetched relay info. Pubkey: ${relayInfo.pubkey ? relayInfo.pubkey.substring(0, 16) + '...' : 'not set'}`, 'INFO');
return relayInfo;
} catch (error) {
log(`Failed to fetch relay info: ${error.message}`, 'ERROR');
throw error;
}
}
// Test WebSocket connection to relay
async function testWebSocketConnection(wsUrl) {
return new Promise((resolve, reject) => {
try {
log(`Testing WebSocket connection to: ${wsUrl}`, 'INFO');
const ws = new WebSocket(wsUrl);
const timeout = setTimeout(() => {
ws.close();
reject(new Error('WebSocket connection timeout (10s)'));
}, 10000);
ws.onopen = () => {
clearTimeout(timeout);
log('WebSocket connection successful', 'INFO');
ws.close();
resolve(true);
};
ws.onerror = (error) => {
clearTimeout(timeout);
log(`WebSocket connection failed: ${error.message || 'Unknown error'}`, 'ERROR');
reject(new Error('WebSocket connection failed'));
};
ws.onclose = (event) => {
if (event.code !== 1000) { // 1000 = normal closure
clearTimeout(timeout);
reject(new Error(`WebSocket closed unexpectedly: ${event.code} ${event.reason}`));
}
};
} catch (error) {
log(`WebSocket test error: ${error.message}`, 'ERROR');
reject(error);
}
});
}
// Check for existing authentication state with multiple API methods and retry logic
async function checkExistingAuthWithRetries() {
console.log('Starting authentication state detection with retry logic...');
const maxAttempts = 3;
const delay = 200; // ms between attempts (reduced from 500ms)
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
console.log(`Authentication detection attempt ${attempt}/${maxAttempts}`);
try {
// Method 1: Try window.NOSTR_LOGIN_LITE.getAuthState()
if (window.NOSTR_LOGIN_LITE && typeof window.NOSTR_LOGIN_LITE.getAuthState === 'function') {
console.log('Trying window.NOSTR_LOGIN_LITE.getAuthState()...');
const authState = window.NOSTR_LOGIN_LITE.getAuthState();
if (authState && authState.pubkey) {
console.log('✅ Auth state found via NOSTR_LOGIN_LITE.getAuthState():', authState.pubkey);
await restoreAuthenticationState(authState.pubkey);
return true;
}
}
// Method 2: Try nlLite.getPublicKey()
if (nlLite && typeof nlLite.getPublicKey === 'function') {
console.log('Trying nlLite.getPublicKey()...');
const pubkey = await nlLite.getPublicKey();
if (pubkey && pubkey.length === 64) {
console.log('✅ Pubkey found via nlLite.getPublicKey():', pubkey);
await restoreAuthenticationState(pubkey);
return true;
}
}
// Method 3: Try window.nostr.getPublicKey() (NIP-07)
if (window.nostr && typeof window.nostr.getPublicKey === 'function') {
console.log('Trying window.nostr.getPublicKey()...');
const pubkey = await window.nostr.getPublicKey();
if (pubkey && pubkey.length === 64) {
console.log('✅ Pubkey found via window.nostr.getPublicKey():', pubkey);
await restoreAuthenticationState(pubkey);
return true;
}
}
// Method 4: Check localStorage directly for NOSTR_LOGIN_LITE data
const localStorageData = localStorage.getItem('NOSTR_LOGIN_LITE_DATA');
if (localStorageData) {
try {
const parsedData = JSON.parse(localStorageData);
if (parsedData.pubkey) {
console.log('✅ Pubkey found in localStorage:', parsedData.pubkey);
await restoreAuthenticationState(parsedData.pubkey);
return true;
}
} catch (parseError) {
console.log('Failed to parse localStorage data:', parseError.message);
}
}
console.log(`❌ Attempt ${attempt}: No authentication found via any method`);
// Wait before next attempt (except for last attempt)
if (attempt < maxAttempts) {
await new Promise(resolve => setTimeout(resolve, delay));
}
} catch (error) {
console.log(`❌ Attempt ${attempt} failed:`, error.message);
if (attempt < maxAttempts) {
await new Promise(resolve => setTimeout(resolve, delay));
}
}
}
console.log('🔍 Authentication detection completed - no existing auth found after all attempts');
return false;
}
// Helper function to restore authentication state
async function restoreAuthenticationState(pubkey) {
console.log('🔄 Restoring authentication state for pubkey:', pubkey);
userPubkey = pubkey;
isLoggedIn = true;
// Show main interface and profile in header
showProfileInHeader();
loadUserProfile();
// Automatically set up relay connection (but don't show admin sections yet)
await setupAutomaticRelayConnection();
console.log('✅ Authentication state restored successfully');
}
// Automatically set up relay connection based on current page URL
async function setupAutomaticRelayConnection(showSections = false) {
try {
// Get the current page URL and convert to WebSocket URL
const currentUrl = window.location.href;
let relayUrl = '';
if (currentUrl.startsWith('https://')) {
relayUrl = currentUrl.replace('https://', 'wss://');
} else if (currentUrl.startsWith('http://')) {
relayUrl = currentUrl.replace('http://', 'ws://');
} else {
// Fallback for development
relayUrl = 'ws://localhost:8888';
}
// Remove any path components to get just the base URL
const url = new URL(relayUrl);
relayUrl = `${url.protocol}//${url.host}`;
// Set the relay URL
relayConnectionUrl.value = relayUrl;
console.log('🔗 Auto-setting relay URL to:', relayUrl);
// Fetch relay info to get pubkey
try {
const httpUrl = relayUrl.replace('ws', 'http').replace('wss', 'https');
const relayInfo = await fetchRelayInfo(httpUrl);
if (relayInfo && relayInfo.pubkey) {
relayPubkey = relayInfo.pubkey;
console.log('🔑 Auto-fetched relay pubkey:', relayPubkey.substring(0, 16) + '...');
} else {
// Use fallback pubkey
relayPubkey = '4f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa';
console.log('⚠️ Using fallback relay pubkey');
}
} catch (error) {
console.log('⚠️ Could not fetch relay info, using fallback pubkey:', error.message);
relayPubkey = '4f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa';
}
// Initialize relay pool for admin API communication
if (!relayPool) {
relayPool = new window.NostrTools.SimplePool();
console.log('🔌 Initialized SimplePool for admin API communication');
}
// Set up subscription to receive admin API responses
await subscribeToConfiguration();
console.log('📡 Subscription established for admin API responses');
// Mark as connected
isRelayConnected = true;
// Update relay info in header
updateRelayInfoInHeader();
// Only show admin sections if explicitly requested
if (showSections) {
updateAdminSectionsVisibility();
}
console.log('✅ Automatic relay connection setup complete');
} catch (error) {
console.error('❌ Failed to setup automatic relay connection:', error);
// Still mark as connected to allow basic functionality
isRelayConnected = true;
if (showSections) {
updateAdminSectionsVisibility();
}
}
}
// Legacy function for backward compatibility
async function checkExistingAuth() {
return await checkExistingAuthWithRetries();
}
// Initialize NOSTR_LOGIN_LITE
async function initializeApp() {
try {
await window.NOSTR_LOGIN_LITE.init({
theme: 'default',
methods: {
extension: true,
local: true,
seedphrase: true,
readonly: true,
connect: true,
remote: true,
otp: false
},
floatingTab: {
enabled: false
}
});
nlLite = window.NOSTR_LOGIN_LITE;
console.log('Nostr login system initialized');
// Check for existing authentication state after initialization
const wasAlreadyLoggedIn = await checkExistingAuth();
if (wasAlreadyLoggedIn) {
console.log('User was already logged in, showing profile in header');
showProfileInHeader();
// Show admin sections since user is already authenticated and relay is connected
updateAdminSectionsVisibility();
} else {
console.log('No existing authentication found, showing login modal');
showLoginModal();
}
// Listen for authentication events
window.addEventListener('nlMethodSelected', handleAuthEvent);
window.addEventListener('nlLogout', handleLogoutEvent);
} catch (error) {
console.log('Failed to initialize Nostr login: ' + error.message);
}
}
// Handle authentication events
function handleAuthEvent(event) {
const { pubkey, method, error } = event.detail;
if (method && pubkey) {
userPubkey = pubkey;
isLoggedIn = true;
console.log(`Login successful! Method: ${method}`);
console.log(`Public key: ${pubkey}`);
// Hide login modal and show profile in header
hideLoginModal();
showProfileInHeader();
loadUserProfile();
// Automatically set up relay connection and show admin sections
setupAutomaticRelayConnection(true);
} else if (error) {
console.log(`Authentication error: ${error}`);
}
}
// Handle logout events
function handleLogoutEvent() {
console.log('Logout event received');
userPubkey = null;
isLoggedIn = false;
currentConfig = null;
// Reset relay connection state
isRelayConnected = false;
relayPubkey = null;
// Reset UI - hide profile and show login modal
hideProfileFromHeader();
showLoginModal();
updateConfigStatus(false);
updateAdminSectionsVisibility();
console.log('Logout event handled successfully');
}
// Update visibility of admin sections based on login and relay connection status
function updateAdminSectionsVisibility() {
const divConfig = document.getElementById('div_config');
const authRulesSection = document.getElementById('authRulesSection');
const databaseStatisticsSection = document.getElementById('databaseStatisticsSection');
const nip17DMSection = document.getElementById('nip17DMSection');
const shouldShow = isLoggedIn && isRelayConnected;
if (divConfig) divConfig.style.display = shouldShow ? 'block' : 'none';
if (authRulesSection) authRulesSection.style.display = shouldShow ? 'block' : 'none';
if (databaseStatisticsSection) databaseStatisticsSection.style.display = shouldShow ? 'block' : 'none';
if (nip17DMSection) nip17DMSection.style.display = shouldShow ? 'block' : 'none';
// Start/stop auto-refresh based on visibility
if (shouldShow && databaseStatisticsSection && databaseStatisticsSection.style.display === 'block') {
// Load statistics immediately, then start auto-refresh
sendStatsQuery().catch(error => {
console.log('Auto-fetch statistics failed: ' + error.message);
});
startStatsAutoRefresh();
// Also load configuration and auth rules automatically when sections become visible
fetchConfiguration().catch(error => {
console.log('Auto-fetch configuration failed: ' + error.message);
});
loadAuthRules().catch(error => {
console.log('Auto-load auth rules failed: ' + error.message);
});
} else {
stopStatsAutoRefresh();
}
// Update countdown display when visibility changes
updateCountdownDisplay();
}
// Show login modal
function showLoginModal() {
if (loginModal && loginModalContainer) {
// Initialize the login UI in the modal
if (window.NOSTR_LOGIN_LITE && typeof window.NOSTR_LOGIN_LITE.embed === 'function') {
window.NOSTR_LOGIN_LITE.embed('#login-modal-container', {
seamless: true
});
}
loginModal.style.display = 'flex';
}
}
// Hide login modal
function hideLoginModal() {
if (loginModal) {
loginModal.style.display = 'none';
}
}
// Show profile in header
function showProfileInHeader() {
if (profileArea) {
profileArea.style.display = 'flex';
}
}
// Hide profile from header
function hideProfileFromHeader() {
if (profileArea) {
profileArea.style.display = 'none';
}
// Also hide logout dropdown if visible
if (logoutDropdown) {
logoutDropdown.style.display = 'none';
}
}
// Update login/logout UI visibility (legacy function - kept for backward compatibility)
function updateLoginLogoutUI() {
// This function is now handled by showProfileInHeader() and hideProfileFromHeader()
// Kept for backward compatibility with any existing code that might call it
}
// Show main interface after login (legacy function - kept for backward compatibility)
function showMainInterface() {
// This function is now handled by showProfileInHeader() and updateAdminSectionsVisibility()
// Kept for backward compatibility with any existing code that might call it
updateAdminSectionsVisibility();
}
// Load user profile using nostr-tools pool
async function loadUserProfile() {
if (!userPubkey) return;
console.log('Loading user profile...');
// Update header display (new system)
if (headerUserName) {
headerUserName.textContent = 'Loading...';
}
// Update legacy elements if they exist (backward compatibility)
if (persistentUserName) {
persistentUserName.textContent = 'Loading...';
}
if (persistentUserAbout) {
persistentUserAbout.textContent = 'Loading...';
}
// Convert hex pubkey to npub for initial display
let displayPubkey = userPubkey;
let npubLink = '';
try {
if (userPubkey && userPubkey.length === 64 && /^[0-9a-fA-F]+$/.test(userPubkey)) {
const npub = window.NostrTools.nip19.npubEncode(userPubkey);
displayPubkey = npub;
npubLink = `<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',
'wss://relay.laantungir.net'];
// Get profile event (kind 0) for the user
const events = await profilePool.querySync(relays, {
kinds: [0],
authors: [userPubkey],
limit: 1
});
if (events.length > 0) {
console.log('Profile event found:', events[0]);
const profile = JSON.parse(events[0].content);
console.log('Parsed profile:', profile);
displayProfile(profile);
} else {
console.log('No profile events found for pubkey:', userPubkey);
// Update header display (new system)
if (headerUserName) {
headerUserName.textContent = 'Anonymous User';
}
// Update legacy elements if they exist (backward compatibility)
if (persistentUserName) {
persistentUserName.textContent = 'Anonymous User';
}
if (persistentUserAbout) {
persistentUserAbout.textContent = 'No profile found';
}
// Keep the npub display
}
// Close the profile pool
profilePool.close(relays);
} catch (error) {
console.log('Profile loading failed: ' + error.message);
// Update header display (new system)
if (headerUserName) {
headerUserName.textContent = 'Error loading profile';
}
// Update legacy elements if they exist (backward compatibility)
if (persistentUserName) {
persistentUserName.textContent = 'Error loading profile';
}
if (persistentUserAbout) {
persistentUserAbout.textContent = error.message;
}
// Keep the npub display
}
}
// Display profile data
function displayProfile(profile) {
const name = profile.name || profile.display_name || profile.displayName || 'Anonymous User';
const about = profile.about || 'No description provided';
const picture = profile.picture || profile.image || null;
// Convert hex pubkey to npub for display
let displayPubkey = userPubkey;
let npubLink = '';
try {
if (userPubkey && userPubkey.length === 64 && /^[0-9a-fA-F]+$/.test(userPubkey)) {
const npub = window.NostrTools.nip19.npubEncode(userPubkey);
displayPubkey = npub;
npubLink = `<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 configuration pool
if (relayPool) {
log('Closing configuration pool...', 'INFO');
const url = relayConnectionUrl.value.trim();
if (url) {
relayPool.close([url]);
}
relayPool = null;
subscriptionId = null;
// Reset subscription flag
isSubscribed = false;
}
await nlLite.logout();
userPubkey = null;
isLoggedIn = false;
currentConfig = null;
// Reset relay connection state
isRelayConnected = false;
relayPubkey = null;
// Reset subscription flag
isSubscribed = false;
// Reset UI - hide profile and show login modal
hideProfileFromHeader();
// showLoginModal() removed - handled by handleLogoutEvent()
updateConfigStatus(false);
updateAdminSectionsVisibility();
log('Logged out successfully', 'INFO');
} catch (error) {
log('Logout failed: ' + error.message, 'ERROR');
}
}
function updateConfigStatus(loaded) {
if (loaded) {
configDisplay.classList.remove('hidden');
} else {
configDisplay.classList.add('hidden');
}
}
// Generate random subscription ID (avoiding colons which are rejected by relay)
function generateSubId() {
// Use only alphanumeric characters, underscores, and hyphens
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-';
let result = '';
for (let i = 0; i < 12; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length));
}
return result;
}
// Configuration subscription using nostr-tools SimplePool
async function subscribeToConfiguration() {
try {
console.log('=== STARTING SIMPLEPOOL CONFIGURATION SUBSCRIPTION ===');
// Prevent multiple simultaneous subscription attempts
if (isSubscribed) {
console.log('Subscription already established, skipping duplicate subscription attempt');
return true;
}
if (!isLoggedIn) {
console.log('WARNING: Not logged in, but proceeding with subscription test');
}
const url = relayConnectionUrl.value.trim();
if (!url) {
console.error('Please enter a relay URL');
return false;
}
console.log(`Connecting to relay via SimplePool: ${url}`);
// Reuse existing pool if available, otherwise create new one
if (!relayPool) {
console.log('Creating new SimplePool instance');
relayPool = new window.NostrTools.SimplePool();
} else {
console.log('Reusing existing SimplePool instance');
}
subscriptionId = generateSubId();
console.log(`Generated subscription ID: ${subscriptionId}`);
console.log(`User pubkey ${userPubkey}`)
// Subscribe to kind 23457 events (admin response events), kind 4 (NIP-04 DMs), and kind 1059 (NIP-17 GiftWrap)
const subscription = relayPool.subscribeMany([url], [{
since: Math.floor(Date.now() / 1000) - 5, // Look back 5 seconds to avoid race condition
kinds: [23457],
authors: [getRelayPubkey()], // Only listen to responses from the relay
"#p": [userPubkey], // Only responses directed to this user
limit: 50
}, {
since: Math.floor(Date.now() / 1000),
kinds: [4], // NIP-04 Direct Messages
authors: [getRelayPubkey()], // Only listen to DMs from the relay
"#p": [userPubkey], // Only DMs directed to this user
limit: 50
}, {
since: Math.floor(Date.now() / 1000), // Start from current time
kinds: [1059], // NIP-17 GiftWrap events
"#p": [userPubkey], // Only GiftWrap events addressed to this user
limit: 50
}], {
async onevent(event) {
console.log('=== EVENT RECEIVED VIA SIMPLEPOOL ===');
console.log('Event data:', event);
console.log('Event kind:', event.kind);
console.log('Event tags:', event.tags);
console.log('Event pubkey:', event.pubkey);
console.log('=== END EVENT ===');
// Handle NIP-04 DMs
if (event.kind === 4) {
console.log('=== NIP-04 DM RECEIVED ===');
try {
// Decrypt the DM content
const decryptedContent = await window.nostr.nip04.decrypt(event.pubkey, event.content);
log(`Received NIP-04 DM from relay: ${decryptedContent.substring(0, 50)}...`, 'INFO');
// Add to inbox
const timestamp = new Date(event.created_at * 1000).toLocaleString();
addMessageToInbox('received', decryptedContent, timestamp, event.pubkey);
// Log for testing
if (typeof logTestEvent === 'function') {
logTestEvent('RECV', `NIP-04 DM: ${decryptedContent}`, 'DM');
}
} catch (decryptError) {
log(`Failed to decrypt NIP-04 DM: ${decryptError.message}`, 'ERROR');
if (typeof logTestEvent === 'function') {
logTestEvent('ERROR', `Failed to decrypt DM: ${decryptError.message}`, 'DM');
}
}
return;
}
// Handle NIP-17 GiftWrap DMs
if (event.kind === 1059) {
console.log('=== NIP-17 GIFTWRAP RECEIVED ===');
try {
// Step 1: Unwrap gift wrap to get seal
const sealJson = await window.nostr.nip44.decrypt(event.pubkey, event.content);
const seal = safeJsonParse(sealJson);
if (!seal || seal.kind !== 13) {
throw new Error('Unwrapped content is not a valid seal (kind 13)');
}
// Step 2: Unseal to get rumor
const rumorJson = await window.nostr.nip44.decrypt(seal.pubkey, seal.content);
const rumor = safeJsonParse(rumorJson);
if (!rumor || rumor.kind !== 14) {
throw new Error('Unsealed content is not a valid rumor (kind 14)');
}
log(`Received NIP-17 DM from relay: ${rumor.content.substring(0, 50)}...`, 'INFO');
// Add to inbox
const timestamp = new Date(event.created_at * 1000).toLocaleString();
addMessageToInbox('received', rumor.content, timestamp, rumor.pubkey);
// Log for testing
if (typeof logTestEvent === 'function') {
logTestEvent('RECV', `NIP-17 DM: ${rumor.content}`, 'DM');
}
} catch (unwrapError) {
log(`Failed to unwrap NIP-17 DM: ${unwrapError.message}`, 'ERROR');
if (typeof logTestEvent === 'function') {
logTestEvent('ERROR', `Failed to unwrap DM: ${unwrapError.message}`, 'DM');
}
}
return;
}
// Handle admin response events (kind 23457)
if (event.kind === 23457) {
// Log all received messages for testing
if (typeof logTestEvent === 'function') {
logTestEvent('RECV', `Admin response event: ${JSON.stringify(event)}`, 'EVENT');
}
// Process admin response event
processAdminResponse(event);
}
},
oneose() {
console.log('EOSE received - End of stored events');
console.log('Current config after EOSE:', currentConfig);
if (!currentConfig) {
console.log('No configuration events were received');
}
},
onclose(reason) {
console.log('Subscription closed:', reason);
updateConfigStatus(false);
}
});
// Store subscription for cleanup
relayPool.currentSubscription = subscription;
// Mark as subscribed to prevent duplicate attempts
isSubscribed = true;
console.log('SimplePool subscription established');
return true;
} catch (error) {
console.error('Configuration subscription failed:', error.message);
console.error('Configuration subscription failed:', error);
console.error('Error stack:', error.stack);
return false;
}
}
// Process admin response events (kind 23457)
async function processAdminResponse(event) {
try {
console.log('=== PROCESSING ADMIN RESPONSE ===');
console.log('Response event:', event);
// Verify this is a kind 23457 admin response event
if (event.kind !== 23457) {
console.log('Ignoring non-admin response event, kind:', event.kind);
return;
}
// Verify the event is from the relay
const expectedRelayPubkey = getRelayPubkey();
if (event.pubkey !== expectedRelayPubkey) {
console.log('Ignoring response from unknown pubkey:', event.pubkey);
return;
}
// Decrypt the NIP-44 encrypted content
const decryptedContent = await decryptFromRelay(event.content);
if (!decryptedContent) {
throw new Error('Failed to decrypt admin response content');
}
console.log('Decrypted admin response:', decryptedContent);
// Parse the decrypted JSON response
const responseData = JSON.parse(decryptedContent);
console.log('Parsed response data:', responseData);
// Log the response for testing
if (typeof logTestEvent === 'function') {
logTestEvent('RECV', `Decrypted response: ${JSON.stringify(responseData)}`, 'RESPONSE');
}
// Handle different types of admin responses
handleAdminResponseData(responseData);
} catch (error) {
console.error('Error processing admin response:', error);
if (typeof logTestEvent === 'function') {
logTestEvent('ERROR', `Failed to process admin response: ${error.message}`, 'ERROR');
}
}
}
// Handle different types of admin response data
function handleAdminResponseData(responseData) {
try {
console.log('=== HANDLING ADMIN RESPONSE DATA ===');
console.log('Response data:', responseData);
console.log('Response query_type:', responseData.query_type);
// Handle auth query responses - updated to match backend response types
if (responseData.query_type &&
(responseData.query_type.includes('auth_rules') ||
responseData.query_type.includes('auth'))) {
console.log('Routing to auth query handler');
handleAuthQueryResponse(responseData);
return;
}
// Handle config update responses specifically
if (responseData.query_type === 'config_update') {
console.log('Routing to config update handler');
handleConfigUpdateResponse(responseData);
return;
}
// Handle config query responses - updated to match backend response types
if (responseData.query_type &&
(responseData.query_type.includes('config') ||
responseData.query_type.startsWith('config_'))) {
console.log('Routing to config query handler');
handleConfigQueryResponse(responseData);
return;
}
// Handle system command responses
if (responseData.command) {
console.log('Routing to system command handler');
handleSystemCommandResponse(responseData);
return;
}
// Handle auth rule modification responses
if (responseData.operation || responseData.rules_processed !== undefined) {
console.log('Routing to auth rule modification handler');
handleAuthRuleResponse(responseData);
return;
}
// Handle stats query responses
if (responseData.query_type === 'stats_query') {
console.log('Routing to stats query handler');
handleStatsQueryResponse(responseData);
return;
}
// Generic response handling
console.log('Using generic response handler');
if (typeof logTestEvent === 'function') {
logTestEvent('RECV', `Generic admin response: ${JSON.stringify(responseData)}`, 'RESPONSE');
}
} catch (error) {
console.error('Error handling admin response data:', error);
if (typeof logTestEvent === 'function') {
logTestEvent('ERROR', `Failed to handle response data: ${error.message}`, 'ERROR');
}
}
}
// Handle config query responses
function handleConfigQueryResponse(responseData) {
console.log('=== CONFIG QUERY RESPONSE ===');
console.log('Query type:', responseData.query_type);
console.log('Total results:', responseData.total_results);
console.log('Data:', responseData.data);
// Convert the config response data to the format expected by displayConfiguration
if (responseData.data && responseData.data.length > 0) {
console.log('Converting config response to display format...');
// Create a synthetic event structure for displayConfiguration
const syntheticEvent = {
id: 'config_response_' + Date.now(),
pubkey: getRelayPubkey(),
created_at: Math.floor(Date.now() / 1000),
kind: 'config_response',
content: 'Configuration from admin API',
tags: []
};
// Convert config data to tags format
responseData.data.forEach(config => {
const key = config.key || config.config_key;
const value = config.value || config.config_value;
if (key && value !== undefined) {
syntheticEvent.tags.push([key, value]);
}
});
console.log('Synthetic event created:', syntheticEvent);
console.log('Calling displayConfiguration with synthetic event...');
// Display the configuration using the original display function
displayConfiguration(syntheticEvent);
// Update relay info in header with config data
updateStoredRelayInfo(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 for logout dropdown
function toggleLogoutDropdown(event) {
if (!logoutDropdown) return;
// Only toggle if clicking on the image, not the text or container
if (event.target === headerUserImage) {
const isVisible = logoutDropdown.style.display === 'block';
logoutDropdown.style.display = isVisible ? 'none' : 'block';
}
}
// Close logout dropdown when clicking outside
document.addEventListener('click', function(event) {
if (profileArea && logoutDropdown && !profileArea.contains(event.target)) {
logoutDropdown.style.display = 'none';
}
});
// Initialize profile area click handler
if (profileArea) {
profileArea.addEventListener('click', toggleLogoutDropdown);
}
// Initialize logout button handler
if (logoutBtn) {
logoutBtn.addEventListener('click', function(e) {
e.stopPropagation(); // Prevent profile area click
logout();
});
}
// Initialize dark mode button handler
const darkModeBtn = document.getElementById('dark-mode-btn');
if (darkModeBtn) {
darkModeBtn.addEventListener('click', function(e) {
e.stopPropagation(); // Prevent profile area click
toggleDarkMode();
});
}
// Initialize relay pubkey container click handler for clipboard copy
const relayPubkeyContainer = document.getElementById('relay-pubkey-container');
if (relayPubkeyContainer) {
relayPubkeyContainer.addEventListener('click', async function() {
const relayPubkeyElement = document.getElementById('relay-pubkey');
if (relayPubkeyElement && relayPubkeyElement.textContent !== 'Loading...') {
try {
// Get the full npub (remove line breaks for clipboard)
const fullNpub = relayPubkeyElement.textContent.replace(/\n/g, '');
await navigator.clipboard.writeText(fullNpub);
// Add copied class for visual feedback
relayPubkeyContainer.classList.add('copied');
// Remove the class after animation completes
setTimeout(() => {
relayPubkeyContainer.classList.remove('copied');
}, 500);
log('Relay npub copied to clipboard', 'INFO');
} catch (error) {
log('Failed to copy relay npub to clipboard', 'ERROR');
}
}
});
}
// Event handlers
fetchConfigBtn.addEventListener('click', function (e) {
e.preventDefault();
e.stopPropagation();
fetchConfiguration().catch(error => {
console.log('Manual fetch configuration failed: ' + error.message);
});
});
// ================================
// AUTH RULES MANAGEMENT FUNCTIONS
// ================================
// Global auth rules state
let currentAuthRules = [];
let editingAuthRule = null;
// DOM elements for auth rules
const authRulesSection = document.getElementById('authRulesSection');
const refreshAuthRulesBtn = document.getElementById('refreshAuthRulesBtn');
const authRulesTableContainer = document.getElementById('authRulesTableContainer');
const authRulesTableBody = document.getElementById('authRulesTableBody');
const authRuleFormContainer = document.getElementById('authRuleFormContainer');
const authRuleForm = document.getElementById('authRuleForm');
const authRuleFormTitle = document.getElementById('authRuleFormTitle');
const saveAuthRuleBtn = document.getElementById('saveAuthRuleBtn');
const cancelAuthRuleBtn = document.getElementById('cancelAuthRuleBtn');
// Show auth rules section after login
function showAuthRulesSection() {
if (authRulesSection) {
authRulesSection.style.display = 'block';
updateAuthRulesStatus('ready');
log('Auth rules section is now available', 'INFO');
}
}
// Hide auth rules section on logout
function hideAuthRulesSection() {
if (authRulesSection) {
authRulesSection.style.display = 'none';
// Add null checks for all elements
if (authRulesTableContainer) {
authRulesTableContainer.style.display = 'none';
}
if (authRuleFormContainer) {
authRuleFormContainer.style.display = 'none';
}
currentAuthRules = [];
editingAuthRule = null;
log('Auth rules section hidden', 'INFO');
}
}
// Update auth rules status indicator (removed - no status element)
function updateAuthRulesStatus(status) {
// Status element removed - no-op
}
// Load auth rules from relay using admin API
async function loadAuthRules() {
try {
log('Loading auth rules via admin API...', 'INFO');
updateAuthRulesStatus('loading');
if (!isLoggedIn || !userPubkey) {
throw new Error('Must be logged in to load auth rules');
}
if (!relayPool) {
throw new Error('SimplePool connection not available');
}
// Create command array for getting all auth rules
const command_array = ["auth_query", "all"];
// Encrypt the command array directly using NIP-44
const encrypted_content = await encryptForRelay(JSON.stringify(command_array));
if (!encrypted_content) {
throw new Error('Failed to encrypt command array');
}
// Create single kind 23456 admin event
const authEvent = {
kind: 23456,
pubkey: userPubkey,
created_at: Math.floor(Date.now() / 1000),
tags: [["p", getRelayPubkey()]],
content: encrypted_content
};
// Sign the event
const signedEvent = await window.nostr.signEvent(authEvent);
if (!signedEvent || !signedEvent.sig) {
throw new Error('Event signing failed');
}
log('Sending auth rules query to relay...', 'INFO');
// Publish via SimplePool with detailed error diagnostics
const url = relayConnectionUrl.value.trim();
const publishPromises = relayPool.publish([url], signedEvent);
// Use Promise.allSettled to capture per-relay outcomes instead of Promise.any
const results = await Promise.allSettled(publishPromises);
// Log detailed publish results for diagnostics
let successCount = 0;
results.forEach((result, index) => {
if (result.status === 'fulfilled') {
successCount++;
console.log(`✅ Auth Rules Query Relay ${index} (${url}): Event published successfully`);
if (typeof logTestEvent === 'function') {
logTestEvent('INFO', `Auth rules query relay ${index} publish success`, 'PUBLISH');
}
} else {
console.error(`❌ Auth Rules Query Relay ${index} (${url}): Publish failed:`, result.reason);
if (typeof logTestEvent === 'function') {
logTestEvent('ERROR', `Auth rules query relay ${index} publish failed: ${result.reason?.message || result.reason}`, 'PUBLISH');
}
}
});
// Throw error if all relays failed
if (successCount === 0) {
const errorDetails = results.map((r, i) => `Relay ${i}: ${r.reason?.message || r.reason}`).join('; ');
throw new Error(`All relays rejected auth rules query event. Details: ${errorDetails}`);
}
log('Auth rules query sent successfully - waiting for response...', 'INFO');
updateAuthRulesStatus('loaded');
} catch (error) {
log(`Failed to load auth rules: ${error.message}`, 'ERROR');
updateAuthRulesStatus('error');
currentAuthRules = [];
displayAuthRules(currentAuthRules);
}
}
// Display auth rules in the table
function displayAuthRules(rules) {
console.log('=== DISPLAY AUTH RULES DEBUG ===');
console.log('authRulesTableBody element:', authRulesTableBody);
console.log('Rules to display:', rules);
console.log('Rules length:', rules ? rules.length : 'undefined');
console.log('authRulesTableContainer display:', authRulesTableContainer ? authRulesTableContainer.style.display : 'element not found');
if (!authRulesTableBody) {
console.log('ERROR: authRulesTableBody element not found');
return;
}
authRulesTableBody.innerHTML = '';
console.log('Cleared existing table content');
if (!rules || rules.length === 0) {
console.log('No rules to display, showing empty message');
const row = document.createElement('tr');
row.innerHTML = `<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');
}
}
// 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');
}
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
let formattedNpub = relayNpub;
if (relayNpub.length === 63) {
formattedNpub = relayNpub.substring(0, 21) + '\n' +
relayNpub.substring(21, 42) + '\n' +
relayNpub.substring(42, 63);
}
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');
}
}
// 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);
});
}
// Update statistics status indicator (disabled - status display removed)
function updateStatsStatus(status, message = '') {
// Status display has been removed from the UI
return;
}
// Utility function to format file size
function formatFileSize(bytes) {
if (!bytes || bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
}
// Utility function to format timestamp
function formatTimestamp(timestamp) {
if (!timestamp) return '-';
const date = new Date(timestamp * 1000);
return date.toLocaleString();
}
// Update statistics cell with flash animation if value changed
function updateStatsCell(cellId, newValue) {
const cell = document.getElementById(cellId);
if (!cell) return;
const currentValue = cell.textContent;
cell.textContent = newValue;
// Flash if value changed
if (currentValue !== newValue && currentValue !== '-') {
cell.classList.add('flash-value');
setTimeout(() => {
cell.classList.remove('flash-value');
}, 500);
}
}
// Start auto-refreshing database statistics every 10 seconds
function startStatsAutoRefresh() {
// Clear any existing interval
stopStatsAutoRefresh();
// Reset countdown
countdownSeconds = 10;
updateCountdownDisplay();
// Start countdown interval - update every second
countdownInterval = setInterval(() => {
countdownSeconds--;
updateCountdownDisplay();
if (countdownSeconds <= 0) {
// Time to refresh
if (isLoggedIn && isRelayConnected) {
log('Auto-refreshing database statistics...', 'INFO');
sendStatsQuery().then(() => {
// Flash button red on successful refresh
flashRefreshButton();
// Reset countdown
countdownSeconds = 10;
updateCountdownDisplay();
}).catch(error => {
log(`Auto-refresh failed: ${error.message}`, 'ERROR');
// Reset countdown even on failure
countdownSeconds = 10;
updateCountdownDisplay();
});
} else {
// Reset countdown if not logged in/connected
countdownSeconds = 10;
updateCountdownDisplay();
}
}
}, 1000); // Update every 1 second
log('Database statistics auto-refresh started (10 second intervals)', 'INFO');
}
// Stop auto-refreshing database statistics
function stopStatsAutoRefresh() {
if (statsAutoRefreshInterval) {
clearInterval(statsAutoRefreshInterval);
statsAutoRefreshInterval = null;
}
if (countdownInterval) {
clearInterval(countdownInterval);
countdownInterval = null;
}
// Reset countdown display
updateCountdownDisplay();
log('Database statistics auto-refresh stopped', 'INFO');
}
// Update countdown display in refresh button
function updateCountdownDisplay() {
const refreshBtn = document.getElementById('refresh-stats-btn');
if (!refreshBtn) return;
if (countdownInterval && isLoggedIn && isRelayConnected) {
// Japanese Kanji numbers: 一 二 三 四 五 六 七 八 九 十
const kanjiNumbers = ['', '一', '二', '三', '四', '五', '六', '七', '八', '九', '十'];
// Show single character counting down from 十 (10) to (0)
if (countdownSeconds >= 0 && countdownSeconds <= 10) {
refreshBtn.textContent = kanjiNumbers[countdownSeconds];
} else {
refreshBtn.textContent = '';
}
} else {
// Show empty when not active
refreshBtn.textContent = '';
}
}
// Flash refresh button red on successful refresh
function flashRefreshButton() {
const refreshBtn = document.getElementById('refresh-stats-btn');
if (!refreshBtn) return;
// Add red flash class
refreshBtn.classList.add('flash-red');
// Remove flash class after animation
setTimeout(() => {
refreshBtn.classList.remove('flash-red');
}, 500); // Match CSS animation duration
}
// Event handlers for test buttons
document.addEventListener('DOMContentLoaded', () => {
// Test button event handlers
const testGetAuthRulesBtn = document.getElementById('test-get-auth-rules-btn');
const testClearAuthRulesBtn = document.getElementById('test-clear-auth-rules-btn');
const testAddBlacklistBtn = document.getElementById('test-add-blacklist-btn');
const testAddWhitelistBtn = document.getElementById('test-add-whitelist-btn');
const testConfigQueryBtn = document.getElementById('test-config-query-btn');
const testPostEventBtn = document.getElementById('test-post-event-btn');
const clearTestLogBtn = document.getElementById('clear-test-log-btn');
const generateTestKeyBtn = document.getElementById('generate-test-key-btn');
if (testGetAuthRulesBtn) {
testGetAuthRulesBtn.addEventListener('click', testGetAuthRules);
}
if (testClearAuthRulesBtn) {
testClearAuthRulesBtn.addEventListener('click', testClearAuthRules);
}
if (testAddBlacklistBtn) {
testAddBlacklistBtn.addEventListener('click', testAddBlacklist);
}
if (testAddWhitelistBtn) {
testAddWhitelistBtn.addEventListener('click', testAddWhitelist);
}
if (testConfigQueryBtn) {
testConfigQueryBtn.addEventListener('click', testConfigQuery);
}
if (testPostEventBtn) {
testPostEventBtn.addEventListener('click', testPostEvent);
}
if (clearTestLogBtn) {
clearTestLogBtn.addEventListener('click', () => {
const testLog = document.getElementById('test-event-log');
if (testLog) {
testLog.innerHTML = '<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);
}
// NIP-17 DM event handlers
if (sendDmBtn) {
sendDmBtn.addEventListener('click', sendNIP17DM);
}
});
// Dark mode functionality
function toggleDarkMode() {
const body = document.body;
const isDarkMode = body.classList.contains('dark-mode');
if (isDarkMode) {
body.classList.remove('dark-mode');
localStorage.setItem('darkMode', 'false');
updateDarkModeButton(false);
log('Switched to light mode', 'INFO');
} else {
body.classList.add('dark-mode');
localStorage.setItem('darkMode', 'true');
updateDarkModeButton(true);
log('Switched to dark mode', 'INFO');
}
}
function updateDarkModeButton(isDarkMode) {
const darkModeBtn = document.getElementById('dark-mode-btn');
if (darkModeBtn) {
darkModeBtn.textContent = isDarkMode ? 'LIGHT MODE' : 'DARK MODE';
}
}
function initializeDarkMode() {
const savedDarkMode = localStorage.getItem('darkMode');
const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
const shouldBeDark = savedDarkMode === 'true' || (savedDarkMode === null && prefersDark);
if (shouldBeDark) {
document.body.classList.add('dark-mode');
updateDarkModeButton(true);
} else {
updateDarkModeButton(false);
}
}
// Initialize the app
document.addEventListener('DOMContentLoaded', () => {
console.log('C-Relay Admin API interface loaded');
// Initialize dark mode
initializeDarkMode();
// Start RELAY letter animation
startRelayAnimation();
// Ensure admin sections are hidden by default on page load
updateAdminSectionsVisibility();
setTimeout(() => {
initializeApp();
// Enhance SimplePool for testing after initialization
setTimeout(enhancePoolForTesting, 2000);
}, 100);
});
// 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();
}