Files
c-relay/api/index.js

3971 lines
146 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;
// Relay connection state
let relayInfo = null;
let isRelayConnected = false;
let relayPubkey = null;
// 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');
// Relay connection elements
const relayConnectionUrl = document.getElementById('relay-connection-url');
const relayPubkeyManual = document.getElementById('relay-pubkey-manual');
const relayConnectionStatus = document.getElementById('relay-connection-status');
const connectRelayBtn = document.getElementById('connect-relay-btn');
const disconnectRelayBtn = document.getElementById('disconnect-relay-btn');
const restartRelayBtn = document.getElementById('restart-relay-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
}
// Show authentication warning message
function showAuthenticationWarning(message) {
// Remove any existing warning
hideAuthenticationWarning();
// Create warning element
const warningDiv = document.createElement('div');
warningDiv.id = 'auth-warning-message';
warningDiv.className = 'auth-warning-message';
warningDiv.innerHTML = `
<div class="warning-content">
<strong>⚠️ Authentication Issue:</strong> ${message}
<br><br>
<small>This usually means your pubkey is not authorized as an admin for this relay.
Please check that you are using the correct admin pubkey that was shown during relay startup.</small>
</div>
`;
// Insert warning at the top of the relay connection section
const relaySection = document.getElementById('relay-connection-section');
if (relaySection) {
relaySection.insertBefore(warningDiv, relaySection.firstChild);
}
log(`Authentication warning displayed: ${message}`, 'WARNING');
}
// Hide authentication warning message
function hideAuthenticationWarning() {
const warningDiv = document.getElementById('auth-warning-message');
if (warningDiv) {
warningDiv.remove();
}
}
// 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);
}
});
}
// Connect to relay (NIP-11 + WebSocket test)
async function connectToRelay() {
try {
const url = relayConnectionUrl.value.trim();
if (!url) {
throw new Error('Please enter a relay URL');
}
// Update UI to show connecting state
updateRelayConnectionStatus('connecting');
connectRelayBtn.disabled = true;
log(`Connecting to relay: ${url}`, 'INFO');
// Clear any previous authentication warnings
hideAuthenticationWarning();
let fetchedRelayInfo;
try {
// Step 1: Try to fetch NIP-11 relay information
fetchedRelayInfo = await fetchRelayInfo(url);
// Check if NIP-11 response includes a pubkey
if (fetchedRelayInfo.pubkey) {
// NIP-11 provided pubkey - populate the manual input field
log(`NIP-11 provided relay pubkey: ${fetchedRelayInfo.pubkey.substring(0, 16)}...`, 'INFO');
relayPubkeyManual.value = fetchedRelayInfo.pubkey;
} else {
// NIP-11 response missing pubkey, check for manual input
log('NIP-11 response missing pubkey, checking for manual input...', 'INFO');
const manualPubkey = relayPubkeyManual.value.trim();
if (!manualPubkey) {
throw new Error('Relay NIP-11 response does not include a pubkey. Please enter the relay pubkey manually (shown during relay startup).');
}
if (!/^[0-9a-fA-F]{64}$/.test(manualPubkey)) {
throw new Error('Manual relay pubkey must be exactly 64 hexadecimal characters');
}
log(`Using manual relay pubkey: ${manualPubkey.substring(0, 16)}...`, 'INFO');
// Add manual pubkey to the fetched relay info
fetchedRelayInfo.pubkey = manualPubkey;
// If relay info was completely empty, create minimal info
if (Object.keys(fetchedRelayInfo).length === 1) {
fetchedRelayInfo = {
name: 'C-Relay (Manual Config)',
description: 'C-Relay instance - pubkey provided manually',
pubkey: manualPubkey,
contact: 'admin@manual.config.relay',
supported_nips: [1, 9, 11, 13, 15, 20, 33, 40, 42],
software: 'https://github.com/0xtrr/c-relay',
version: '1.0.0'
};
}
}
} catch (nip11Error) {
// If NIP-11 completely fails (network error, etc.), require manual pubkey
const manualPubkey = relayPubkeyManual.value.trim();
if (!manualPubkey) {
throw new Error(`NIP-11 fetch failed: ${nip11Error.message}. Please enter the relay pubkey manually if the relay hasn't been configured yet.`);
}
if (!/^[0-9a-fA-F]{64}$/.test(manualPubkey)) {
throw new Error('Manual relay pubkey must be exactly 64 hexadecimal characters');
}
log(`NIP-11 failed, using manual relay pubkey: ${manualPubkey.substring(0, 16)}...`, 'INFO');
// Create minimal relay info with manual pubkey
fetchedRelayInfo = {
name: 'C-Relay (Manual Config)',
description: 'C-Relay instance - pubkey provided manually',
pubkey: manualPubkey,
contact: 'admin@manual.config.relay',
supported_nips: [1, 9, 11, 13, 15, 20, 33, 40, 42],
software: 'https://github.com/0xtrr/c-relay',
version: '1.0.0'
};
}
// Step 2: Test WebSocket connection
await testWebSocketConnection(url);
// Step 3: Update global state
relayInfo = fetchedRelayInfo;
relayPubkey = fetchedRelayInfo.pubkey;
isRelayConnected = true;
// Step 4: Update UI
updateRelayConnectionStatus('connected');
updateAdminSectionsVisibility();
// Step 5: Relay URL updated
// Step 6: Automatically load configuration and auth rules
log('Relay connected successfully. Auto-loading configuration and auth rules...', 'INFO');
// Auto-fetch configuration
setTimeout(() => {
fetchConfiguration().catch(error => {
log('Auto-fetch configuration failed: ' + error.message, 'ERROR');
});
}, 500);
// Auto-fetch auth rules
setTimeout(() => {
loadAuthRules().catch(error => {
log('Auto-fetch auth rules failed: ' + error.message, 'ERROR');
});
}, 1000);
// Auto-fetch database statistics
setTimeout(() => {
sendStatsQuery().catch(error => {
log('Auto-fetch statistics failed: ' + error.message, 'ERROR');
});
}, 1500);
log(`Successfully connected to relay: ${relayInfo.name || 'Unknown'}`, 'INFO');
} catch (error) {
log(`Failed to connect to relay: ${error.message}`, 'ERROR');
// Check if this is an authentication-related error
if (error.message.includes('authentication') ||
error.message.includes('auth') ||
error.message.includes('permission') ||
error.message.includes('unauthorized') ||
error.message.includes('forbidden')) {
updateRelayConnectionStatus('auth_error');
showAuthenticationWarning(error.message);
} else {
updateRelayConnectionStatus('error');
}
// Reset state on failure
relayInfo = null;
relayPubkey = null;
isRelayConnected = false;
} finally {
connectRelayBtn.disabled = false;
}
}
// Disconnect from relay
function disconnectFromRelay() {
try {
log('Disconnecting from relay...', 'INFO');
// Clean up relay pool if exists
if (relayPool) {
const url = relayConnectionUrl.value.trim();
if (url) {
relayPool.close([url]);
}
relayPool = null;
subscriptionId = null;
}
// Reset state
relayInfo = null;
relayPubkey = null;
isRelayConnected = false;
// Update UI
updateRelayConnectionStatus('disconnected');
hideRelayInfo();
updateAdminSectionsVisibility();
// Hide any authentication warnings
hideAuthenticationWarning();
log('Disconnected from relay', 'INFO');
} catch (error) {
log(`Error during relay disconnection: ${error.message}`, 'ERROR');
}
}
// Update relay connection status UI
function updateRelayConnectionStatus(status) {
if (!relayConnectionStatus) return;
switch (status) {
case 'connecting':
relayConnectionStatus.textContent = 'CONNECTING...';
relayConnectionStatus.className = 'status connected';
connectRelayBtn.disabled = true;
disconnectRelayBtn.disabled = true;
restartRelayBtn.disabled = true;
break;
case 'connected':
relayConnectionStatus.textContent = 'CONNECTED';
relayConnectionStatus.className = 'status connected';
connectRelayBtn.disabled = true;
disconnectRelayBtn.disabled = false;
restartRelayBtn.disabled = false;
break;
case 'disconnected':
relayConnectionStatus.textContent = 'NOT CONNECTED';
relayConnectionStatus.className = 'status disconnected';
connectRelayBtn.disabled = false;
disconnectRelayBtn.disabled = true;
restartRelayBtn.disabled = true;
break;
case 'error':
relayConnectionStatus.textContent = 'CONNECTION FAILED';
relayConnectionStatus.className = 'status error';
connectRelayBtn.disabled = false;
disconnectRelayBtn.disabled = true;
restartRelayBtn.disabled = true;
break;
case 'auth_error':
relayConnectionStatus.textContent = 'AUTHENTICATION FAILED';
relayConnectionStatus.className = 'status error';
connectRelayBtn.disabled = false;
disconnectRelayBtn.disabled = true;
restartRelayBtn.disabled = true;
break;
}
}
// Hide relay information display (placeholder for removed functionality)
function hideRelayInfo() {
// Relay info display functionality has been removed
console.log('Relay info display functionality has been removed');
}
// 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 attempt to connect to relay after restoring authentication state
console.log('✅ Authentication state restored - automatically attempting to connect to relay...');
setTimeout(() => {
connectToRelay().catch(error => {
console.log(`Automatic relay connection failed: ${error.message}`);
// Check if this is an authentication-related error
if (error.message.includes('authentication') ||
error.message.includes('auth') ||
error.message.includes('permission') ||
error.message.includes('unauthorized') ||
error.message.includes('forbidden')) {
showAuthenticationWarning(error.message);
}
});
}, 500); // Small delay to allow profile loading to complete
console.log('✅ Authentication state restored successfully');
}
// 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();
} 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 attempt to connect to relay after successful login
console.log('Login successful. Automatically attempting to connect to relay...');
setTimeout(() => {
connectToRelay().catch(error => {
console.log(`Automatic relay connection failed: ${error.message}`);
// Check if this is an authentication-related error
if (error.message.includes('authentication') ||
error.message.includes('auth') ||
error.message.includes('permission') ||
error.message.includes('unauthorized')) {
showAuthenticationWarning(error.message);
}
});
}, 500); // Small delay to allow profile loading to complete
} else if (error) {
console.log(`Authentication error: ${error}`);
}
}
// Handle logout events
function handleLogoutEvent() {
console.log('Logout event received');
userPubkey = null;
isLoggedIn = false;
currentConfig = null;
// Clean up relay connection
disconnectFromRelay();
// Hide any authentication warnings
hideAuthenticationWarning();
// 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') {
startStatsAutoRefresh();
} 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 relay connection
disconnectFromRelay();
// Clean up configuration pool
if (relayPool) {
log('Closing configuration pool...', 'INFO');
const url = relayConnectionUrl.value.trim();
if (url) {
relayPool.close([url]);
}
relayPool = null;
subscriptionId = null;
}
await nlLite.logout();
userPubkey = null;
isLoggedIn = false;
currentConfig = null;
// Hide any authentication warnings
hideAuthenticationWarning();
// Reset UI - hide profile and show login modal
hideProfileFromHeader();
// showLoginModal() removed - handled by handleLogoutEvent()
updateConfigStatus(false);
updateAdminSectionsVisibility();
log('Logged out successfully', 'INFO');
} catch (error) {
log('Logout failed: ' + error.message, 'ERROR');
}
}
function updateConfigStatus(loaded) {
if (loaded) {
configDisplay.classList.remove('hidden');
} else {
configDisplay.classList.add('hidden');
}
}
// Generate random subscription ID (avoiding colons which are rejected by relay)
function generateSubId() {
// Use only alphanumeric characters, underscores, and hyphens
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-';
let result = '';
for (let i = 0; i < 12; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length));
}
return result;
}
// Configuration subscription using nostr-tools SimplePool
async function subscribeToConfiguration() {
try {
console.log('=== STARTING SIMPLEPOOL CONFIGURATION SUBSCRIPTION ===');
if (!isLoggedIn) {
console.log('WARNING: Not logged in, but proceeding with subscription test');
}
const url = relayConnectionUrl.value.trim();
if (!url) {
console.error('Please enter a relay URL');
return false;
}
console.log(`Connecting to relay via SimplePool: ${url}`);
// Clean up existing pool
if (relayPool) {
console.log('Closing existing pool connection');
relayPool.close([url]);
relayPool = null;
subscriptionId = null;
}
// Create new SimplePool instance
relayPool = new window.NostrTools.SimplePool();
subscriptionId = generateSubId();
console.log(`Generated subscription ID: ${subscriptionId}`);
console.log(`User pubkey ${userPubkey}`)
// Subscribe to kind 23457 events (admin response events), kind 4 (NIP-04 DMs), and kind 1059 (NIP-17 GiftWrap)
const subscription = relayPool.subscribeMany([url], [{
since: Math.floor(Date.now() / 1000) - 5, // Look back 5 seconds to avoid race condition
kinds: [23457],
authors: [getRelayPubkey()], // Only listen to responses from the relay
"#p": [userPubkey], // Only responses directed to this user
limit: 50
}, {
since: Math.floor(Date.now() / 1000),
kinds: [4], // NIP-04 Direct Messages
authors: [getRelayPubkey()], // Only listen to DMs from the relay
"#p": [userPubkey], // Only DMs directed to this user
limit: 50
}, {
kinds: [1059], // NIP-17 GiftWrap events
"#p": [userPubkey], // Only GiftWrap events addressed to this user
limit: 50
}], {
async onevent(event) {
console.log('=== EVENT RECEIVED VIA SIMPLEPOOL ===');
console.log('Event data:', event);
console.log('Event kind:', event.kind);
console.log('Event tags:', event.tags);
console.log('Event pubkey:', event.pubkey);
console.log('=== END EVENT ===');
// Handle NIP-04 DMs
if (event.kind === 4) {
console.log('=== NIP-04 DM RECEIVED ===');
try {
// Decrypt the DM content
const decryptedContent = await window.nostr.nip04.decrypt(event.pubkey, event.content);
log(`Received NIP-04 DM from relay: ${decryptedContent.substring(0, 50)}...`, 'INFO');
// Add to inbox
const timestamp = new Date(event.created_at * 1000).toLocaleString();
addMessageToInbox('received', decryptedContent, timestamp, event.pubkey);
// Log for testing
if (typeof logTestEvent === 'function') {
logTestEvent('RECV', `NIP-04 DM: ${decryptedContent}`, 'DM');
}
} catch (decryptError) {
log(`Failed to decrypt NIP-04 DM: ${decryptError.message}`, 'ERROR');
if (typeof logTestEvent === 'function') {
logTestEvent('ERROR', `Failed to decrypt DM: ${decryptError.message}`, 'DM');
}
}
return;
}
// Handle NIP-17 GiftWrap DMs
if (event.kind === 1059) {
console.log('=== NIP-17 GIFTWRAP RECEIVED ===');
try {
// Step 1: Unwrap gift wrap to get seal
const sealJson = await window.nostr.nip44.decrypt(event.pubkey, event.content);
const seal = safeJsonParse(sealJson);
if (!seal || seal.kind !== 13) {
throw new Error('Unwrapped content is not a valid seal (kind 13)');
}
// Step 2: Unseal to get rumor
const rumorJson = await window.nostr.nip44.decrypt(seal.pubkey, seal.content);
const rumor = safeJsonParse(rumorJson);
if (!rumor || rumor.kind !== 14) {
throw new Error('Unsealed content is not a valid rumor (kind 14)');
}
log(`Received NIP-17 DM from relay: ${rumor.content.substring(0, 50)}...`, 'INFO');
// Add to inbox
const timestamp = new Date(event.created_at * 1000).toLocaleString();
addMessageToInbox('received', rumor.content, timestamp, rumor.pubkey);
// Log for testing
if (typeof logTestEvent === 'function') {
logTestEvent('RECV', `NIP-17 DM: ${rumor.content}`, 'DM');
}
} catch (unwrapError) {
log(`Failed to unwrap NIP-17 DM: ${unwrapError.message}`, 'ERROR');
if (typeof logTestEvent === 'function') {
logTestEvent('ERROR', `Failed to unwrap DM: ${unwrapError.message}`, 'DM');
}
}
return;
}
// Handle admin response events (kind 23457)
if (event.kind === 23457) {
// Log all received messages for testing
if (typeof logTestEvent === 'function') {
logTestEvent('RECV', `Admin response event: ${JSON.stringify(event)}`, 'EVENT');
}
// Process admin response event
processAdminResponse(event);
}
},
oneose() {
console.log('EOSE received - End of stored events');
console.log('Current config after EOSE:', currentConfig);
if (!currentConfig) {
console.log('No configuration events were received');
}
},
onclose(reason) {
console.log('Subscription closed:', reason);
updateConfigStatus(false);
}
});
// Store subscription for cleanup
relayPool.currentSubscription = subscription;
console.log('SimplePool subscription established');
return true;
} catch (error) {
console.error('Configuration subscription failed:', error.message);
console.error('Configuration subscription failed:', error);
console.error('Error stack:', error.stack);
return false;
}
}
// Process admin response events (kind 23457)
async function processAdminResponse(event) {
try {
console.log('=== PROCESSING ADMIN RESPONSE ===');
console.log('Response event:', event);
// Verify this is a kind 23457 admin response event
if (event.kind !== 23457) {
console.log('Ignoring non-admin response event, kind:', event.kind);
return;
}
// Verify the event is from the relay
const expectedRelayPubkey = getRelayPubkey();
if (event.pubkey !== expectedRelayPubkey) {
console.log('Ignoring response from unknown pubkey:', event.pubkey);
return;
}
// Decrypt the NIP-44 encrypted content
const decryptedContent = await decryptFromRelay(event.content);
if (!decryptedContent) {
throw new Error('Failed to decrypt admin response content');
}
console.log('Decrypted admin response:', decryptedContent);
// Parse the decrypted JSON response
const responseData = JSON.parse(decryptedContent);
console.log('Parsed response data:', responseData);
// Log the response for testing
if (typeof logTestEvent === 'function') {
logTestEvent('RECV', `Decrypted response: ${JSON.stringify(responseData)}`, 'RESPONSE');
}
// Handle different types of admin responses
handleAdminResponseData(responseData);
} catch (error) {
console.error('Error processing admin response:', error);
if (typeof logTestEvent === 'function') {
logTestEvent('ERROR', `Failed to process admin response: ${error.message}`, 'ERROR');
}
}
}
// Handle different types of admin response data
function handleAdminResponseData(responseData) {
try {
console.log('=== HANDLING ADMIN RESPONSE DATA ===');
console.log('Response data:', responseData);
console.log('Response query_type:', responseData.query_type);
// Handle auth query responses - updated to match backend response types
if (responseData.query_type &&
(responseData.query_type.includes('auth_rules') ||
responseData.query_type.includes('auth'))) {
console.log('Routing to auth query handler');
handleAuthQueryResponse(responseData);
return;
}
// Handle config update responses specifically
if (responseData.query_type === 'config_update') {
console.log('Routing to config update handler');
handleConfigUpdateResponse(responseData);
return;
}
// Handle config query responses - updated to match backend response types
if (responseData.query_type &&
(responseData.query_type.includes('config') ||
responseData.query_type.startsWith('config_'))) {
console.log('Routing to config query handler');
handleConfigQueryResponse(responseData);
return;
}
// Handle system command responses
if (responseData.command) {
console.log('Routing to system command handler');
handleSystemCommandResponse(responseData);
return;
}
// Handle auth rule modification responses
if (responseData.operation || responseData.rules_processed !== undefined) {
console.log('Routing to auth rule modification handler');
handleAuthRuleResponse(responseData);
return;
}
// Handle stats query responses
if (responseData.query_type === 'stats_query') {
console.log('Routing to stats query handler');
handleStatsQueryResponse(responseData);
return;
}
// Generic response handling
console.log('Using generic response handler');
if (typeof logTestEvent === 'function') {
logTestEvent('RECV', `Generic admin response: ${JSON.stringify(responseData)}`, 'RESPONSE');
}
} catch (error) {
console.error('Error handling admin response data:', error);
if (typeof logTestEvent === 'function') {
logTestEvent('ERROR', `Failed to handle response data: ${error.message}`, 'ERROR');
}
}
}
// Handle config query responses
function handleConfigQueryResponse(responseData) {
console.log('=== CONFIG QUERY RESPONSE ===');
console.log('Query type:', responseData.query_type);
console.log('Total results:', responseData.total_results);
console.log('Data:', responseData.data);
// Convert the config response data to the format expected by displayConfiguration
if (responseData.data && responseData.data.length > 0) {
console.log('Converting config response to display format...');
// Create a synthetic event structure for displayConfiguration
const syntheticEvent = {
id: 'config_response_' + Date.now(),
pubkey: getRelayPubkey(),
created_at: Math.floor(Date.now() / 1000),
kind: 'config_response',
content: 'Configuration from admin API',
tags: []
};
// Convert config data to tags format
responseData.data.forEach(config => {
const key = config.key || config.config_key;
const value = config.value || config.config_value;
if (key && value !== undefined) {
syntheticEvent.tags.push([key, value]);
}
});
console.log('Synthetic event created:', syntheticEvent);
console.log('Calling displayConfiguration with synthetic event...');
// Display the configuration using the original display function
displayConfiguration(syntheticEvent);
log(`Configuration loaded: ${responseData.total_results} parameters`, 'INFO');
} else {
console.log('No configuration data received');
updateConfigStatus(false);
}
// Also log to test interface for debugging
if (typeof logTestEvent === 'function') {
logTestEvent('RECV', `Config query response: ${responseData.query_type}, ${responseData.total_results} results`, 'CONFIG_QUERY');
if (responseData.data && responseData.data.length > 0) {
logTestEvent('RECV', '=== CONFIGURATION VALUES ===', 'CONFIG');
responseData.data.forEach((config, index) => {
const key = config.key || config.config_key || `config_${index}`;
const value = config.value || config.config_value || 'undefined';
const category = config.category || 'general';
const dataType = config.data_type || 'string';
logTestEvent('RECV', `${key}: ${value} (${dataType}, ${category})`, 'CONFIG');
});
logTestEvent('RECV', '=== END CONFIGURATION VALUES ===', 'CONFIG');
} else {
logTestEvent('RECV', 'No configuration values found', 'CONFIG_QUERY');
}
}
}
// Handle config update responses
function handleConfigUpdateResponse(responseData) {
console.log('=== CONFIG UPDATE RESPONSE ===');
console.log('Query type:', responseData.query_type);
console.log('Status:', responseData.status);
console.log('Data:', responseData.data);
if (responseData.status === 'success') {
const updatesApplied = responseData.updates_applied || 0;
log(`Configuration updated successfully: ${updatesApplied} parameters changed`, 'INFO');
// Show success message with details
if (responseData.data && Array.isArray(responseData.data)) {
responseData.data.forEach((config, index) => {
if (config.status === 'success') {
log(`${config.key}: ${config.value} (${config.data_type})`, 'INFO');
} else {
log(`${config.key}: ${config.error || 'Failed to update'}`, 'ERROR');
}
});
}
// Configuration updated successfully - user can manually refresh using Fetch Config button
log('Configuration updated successfully. Click "Fetch Config" to refresh the display.', 'INFO');
} else {
const errorMessage = responseData.message || responseData.error || 'Unknown error';
log(`Configuration update failed: ${errorMessage}`, 'ERROR');
// Show detailed error information if available
if (responseData.data && Array.isArray(responseData.data)) {
responseData.data.forEach((config, index) => {
if (config.status === 'error') {
log(`${config.key}: ${config.error || 'Failed to update'}`, 'ERROR');
}
});
}
}
// Log to test interface for debugging
if (typeof logTestEvent === 'function') {
logTestEvent('RECV', `Config update response: ${responseData.status}`, 'CONFIG_UPDATE');
if (responseData.data && responseData.data.length > 0) {
responseData.data.forEach((config, index) => {
const status = config.status === 'success' ? '✓' : '✗';
const message = config.status === 'success' ?
`${config.key} = ${config.value}` :
`${config.key}: ${config.error || 'Failed'}`;
logTestEvent('RECV', `${status} ${message}`, 'CONFIG_UPDATE');
});
} else {
logTestEvent('RECV', 'No configuration update details received', 'CONFIG_UPDATE');
}
}
}
// Handle auth query responses
function handleAuthQueryResponse(responseData) {
console.log('=== AUTH QUERY RESPONSE ===');
console.log('Query type:', responseData.query_type);
console.log('Total results:', responseData.total_results);
console.log('Data:', responseData.data);
// Update the current auth rules with the response data
if (responseData.data && Array.isArray(responseData.data)) {
currentAuthRules = responseData.data;
console.log('Updated currentAuthRules with', currentAuthRules.length, 'rules');
// Always show the auth rules table when we receive data (no VIEW RULES button anymore)
console.log('Auto-showing auth rules table since we received data...');
showAuthRulesTable();
updateAuthRulesStatus('loaded');
log(`Loaded ${responseData.total_results} auth rules from relay`, 'INFO');
} else {
currentAuthRules = [];
console.log('No auth rules data received, cleared currentAuthRules');
// Show empty table (no VIEW RULES button anymore)
console.log('Auto-showing auth rules table with empty data...');
showAuthRulesTable();
updateAuthRulesStatus('loaded');
log('No auth rules found on relay', 'INFO');
}
if (typeof logTestEvent === 'function') {
logTestEvent('RECV', `Auth query response: ${responseData.query_type}, ${responseData.total_results} results`, 'AUTH_QUERY');
if (responseData.data && responseData.data.length > 0) {
responseData.data.forEach((rule, index) => {
logTestEvent('RECV', `Rule ${index + 1}: ${rule.rule_type} - ${rule.pattern_value || rule.rule_target}`, 'AUTH_RULE');
});
} else {
logTestEvent('RECV', 'No auth rules found', 'AUTH_QUERY');
}
}
}
// Handle system command responses
function handleSystemCommandResponse(responseData) {
console.log('=== SYSTEM COMMAND RESPONSE ===');
console.log('Command:', responseData.command);
console.log('Status:', responseData.status);
// Handle delete auth rule responses
if (responseData.command === 'delete_auth_rule') {
if (responseData.status === 'success') {
log('Auth rule deleted successfully', 'INFO');
// Refresh the auth rules display
loadAuthRules();
} else {
log(`Failed to delete auth rule: ${responseData.message || 'Unknown error'}`, 'ERROR');
}
}
// Handle clear all auth rules responses
if (responseData.command === 'clear_all_auth_rules') {
if (responseData.status === 'success') {
const rulesCleared = responseData.rules_cleared || 0;
log(`Successfully cleared ${rulesCleared} auth rules`, 'INFO');
// Clear local auth rules and refresh display
currentAuthRules = [];
displayAuthRules(currentAuthRules);
} else {
log(`Failed to clear auth rules: ${responseData.message || 'Unknown error'}`, 'ERROR');
}
}
if (typeof logTestEvent === 'function') {
logTestEvent('RECV', `System command response: ${responseData.command} - ${responseData.status}`, 'SYSTEM_CMD');
}
}
// Handle auth rule modification responses
function handleAuthRuleResponse(responseData) {
console.log('=== AUTH RULE MODIFICATION RESPONSE ===');
console.log('Operation:', responseData.operation);
console.log('Status:', responseData.status);
// Handle auth rule addition/modification responses
if (responseData.status === 'success') {
const rulesProcessed = responseData.rules_processed || 0;
log(`Successfully processed ${rulesProcessed} auth rule modifications`, 'INFO');
// Refresh the auth rules display to show the new rules
if (authRulesTableContainer && authRulesTableContainer.style.display !== 'none') {
loadAuthRules();
}
} else {
log(`Failed to process auth rule modifications: ${responseData.message || 'Unknown error'}`, 'ERROR');
}
if (typeof logTestEvent === 'function') {
logTestEvent('RECV', `Auth rule response: ${responseData.operation} - ${responseData.status}`, 'AUTH_RULE');
if (responseData.processed_rules) {
responseData.processed_rules.forEach((rule, index) => {
logTestEvent('RECV', `Processed rule ${index + 1}: ${rule.rule_type} - ${rule.pattern_value || rule.rule_target}`, 'AUTH_RULE');
});
}
}
}
// Helper function to decrypt content from relay using NIP-44
async function decryptFromRelay(encryptedContent) {
try {
console.log('Decrypting content from relay...');
// Get the relay public key for decryption
const relayPubkey = getRelayPubkey();
// Use NIP-07 extension's NIP-44 decrypt method
if (!window.nostr || !window.nostr.nip44) {
throw new Error('NIP-44 decryption not available via NIP-07 extension');
}
const decryptedContent = await window.nostr.nip44.decrypt(relayPubkey, encryptedContent);
if (!decryptedContent) {
throw new Error('NIP-44 decryption returned empty result');
}
console.log('Successfully decrypted content from relay');
return decryptedContent;
} catch (error) {
console.error('NIP-44 decryption failed:', error);
throw error;
}
}
// Fetch configuration using admin API
async function fetchConfiguration() {
try {
console.log('=== FETCHING CONFIGURATION VIA ADMIN API ===');
// Require both login and relay connection
if (!isLoggedIn || !userPubkey) {
throw new Error('Must be logged in to fetch configuration');
}
if (!isRelayConnected || !relayPubkey) {
throw new Error('Must be connected to relay to fetch configuration. Please use the Relay Connection section first.');
}
// First establish subscription to receive responses
const subscriptionResult = await subscribeToConfiguration();
if (!subscriptionResult) {
throw new Error('Failed to establish admin response subscription');
}
// Wait a moment for subscription to be established
await new Promise(resolve => setTimeout(resolve, 500));
// Send config query command if logged in
if (isLoggedIn && userPubkey && relayPool) {
console.log('Sending config query command...');
// Create command array for getting configuration
const command_array = ["config_query", "all"];
// Encrypt the command array directly using NIP-44
const encrypted_content = await encryptForRelay(JSON.stringify(command_array));
if (!encrypted_content) {
throw new Error('Failed to encrypt command array');
}
// Create single kind 23456 admin event
const configEvent = {
kind: 23456,
pubkey: userPubkey,
created_at: Math.floor(Date.now() / 1000),
tags: [["p", getRelayPubkey()]],
content: encrypted_content
};
// Sign the event
const signedEvent = await window.nostr.signEvent(configEvent);
if (!signedEvent || !signedEvent.sig) {
throw new Error('Event signing failed');
}
console.log('Config query event signed, publishing...');
// Publish via SimplePool with detailed error diagnostics
const url = relayConnectionUrl.value.trim();
const publishPromises = relayPool.publish([url], signedEvent);
// Use Promise.allSettled to capture per-relay outcomes instead of Promise.any
const results = await Promise.allSettled(publishPromises);
// Log detailed publish results for diagnostics
let successCount = 0;
results.forEach((result, index) => {
if (result.status === 'fulfilled') {
successCount++;
console.log(`✅ Relay ${index} (${url}): Event published successfully`);
if (typeof logTestEvent === 'function') {
logTestEvent('INFO', `Relay ${index} publish success`, 'PUBLISH');
}
} else {
console.error(`❌ Relay ${index} (${url}): Publish failed:`, result.reason);
if (typeof logTestEvent === 'function') {
logTestEvent('ERROR', `Relay ${index} publish failed: ${result.reason?.message || result.reason}`, 'PUBLISH');
}
}
});
// Throw error if all relays failed
if (successCount === 0) {
const errorDetails = results.map((r, i) => `Relay ${i}: ${r.reason?.message || r.reason}`).join('; ');
throw new Error(`All relays rejected the event. Details: ${errorDetails}`);
}
console.log('Config query command sent successfully - waiting for response...');
} else {
console.log('Not logged in - only subscription established for testing');
}
return true;
} catch (error) {
console.error('Failed to fetch configuration:', error);
return false;
}
}
function displayConfiguration(event) {
try {
console.log('=== DISPLAYING CONFIGURATION EVENT ===');
console.log('Event received for display:', event);
currentConfig = event;
// Clear existing table
configTableBody.innerHTML = '';
// Display tags (editable configuration parameters only)
console.log(`Processing ${event.tags.length} configuration parameters`);
event.tags.forEach((tag, index) => {
if (tag.length >= 2) {
const row = document.createElement('tr');
const key = tag[0];
const value = tag[1];
// Create editable input for value
const valueInput = document.createElement('input');
valueInput.type = 'text';
valueInput.value = value;
valueInput.className = 'config-value-input';
valueInput.dataset.key = key;
valueInput.dataset.originalValue = value;
valueInput.dataset.rowIndex = index;
// Create clickable Actions cell
const actionsCell = document.createElement('td');
actionsCell.className = 'config-actions-cell';
actionsCell.textContent = 'SAVE';
actionsCell.dataset.key = key;
actionsCell.dataset.originalValue = value;
actionsCell.dataset.rowIndex = index;
// Initially hide the SAVE text
actionsCell.style.color = 'transparent';
// Show SAVE text and make clickable when value changes
valueInput.addEventListener('input', function () {
if (this.value !== this.dataset.originalValue) {
actionsCell.style.color = 'var(--primary-color)';
actionsCell.style.cursor = 'pointer';
actionsCell.onclick = () => saveIndividualConfig(key, valueInput.value, valueInput.dataset.originalValue, actionsCell);
} else {
actionsCell.style.color = 'transparent';
actionsCell.style.cursor = 'default';
actionsCell.onclick = null;
}
});
row.innerHTML = `<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();
});
}
// Event handlers
fetchConfigBtn.addEventListener('click', function (e) {
e.preventDefault();
e.stopPropagation();
fetchConfiguration().catch(error => {
console.log('Manual fetch configuration failed: ' + error.message);
});
});
// Relay connection event handlers
connectRelayBtn.addEventListener('click', function (e) {
e.preventDefault();
e.stopPropagation();
connectToRelay().catch(error => {
console.log('Relay connection failed: ' + error.message);
});
});
disconnectRelayBtn.addEventListener('click', function (e) {
e.preventDefault();
e.stopPropagation();
disconnectFromRelay();
});
restartRelayBtn.addEventListener('click', function (e) {
e.preventDefault();
e.stopPropagation();
sendRestartCommand().catch(error => {
log(`Restart command failed: ${error.message}`, 'ERROR');
});
});
// ================================
// 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);
}
}
// Helper function to get relay pubkey
function getRelayPubkey() {
// Use the dynamically fetched relay pubkey if available
if (relayPubkey && isRelayConnected) {
return relayPubkey;
}
// Fallback to hardcoded value for testing/development
log('Warning: Using hardcoded relay pubkey. Please connect to relay first.', 'WARNING');
return '4f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa';
}
// Enhanced SimplePool message handler to capture test responses
function enhancePoolForTesting() {
// SimplePool handles message parsing automatically, so we just need to
// ensure our event handlers log appropriately. This is already done
// in the subscription onevent callback.
console.log('SimplePool enhanced for testing - automatic message handling enabled');
}
// Generate random test pubkey function
function generateRandomTestKey() {
// Generate 32 random bytes (64 hex characters) for a valid pubkey
const randomBytes = new Uint8Array(32);
crypto.getRandomValues(randomBytes);
// Convert to hex string
const hexPubkey = Array.from(randomBytes)
.map(b => b.toString(16).padStart(2, '0'))
.join('');
// Set the generated key in the input field
const testPubkeyInput = document.getElementById('test-pubkey-input');
if (testPubkeyInput) {
testPubkeyInput.value = hexPubkey;
logTestEvent('INFO', `Generated random test pubkey: ${hexPubkey.substring(0, 16)}...`, 'KEYGEN');
}
return hexPubkey;
}
// ================================
// DATABASE STATISTICS FUNCTIONS
// ================================
// Send restart command to restart the relay using Administrator API
async function sendRestartCommand() {
if (!isLoggedIn || !userPubkey) {
log('Must be logged in to restart relay', 'ERROR');
return;
}
if (!relayPool) {
log('SimplePool connection not available', 'ERROR');
return;
}
try {
log('Sending restart command to relay...', 'INFO');
// Create command array for restart
const command_array = ["system_command", "restart"];
// Encrypt the command array directly using NIP-44
const encrypted_content = await encryptForRelay(JSON.stringify(command_array));
if (!encrypted_content) {
throw new Error('Failed to encrypt command array');
}
// Create single kind 23456 admin event
const restartEvent = {
kind: 23456,
pubkey: userPubkey,
created_at: Math.floor(Date.now() / 1000),
tags: [["p", getRelayPubkey()]],
content: encrypted_content
};
// Sign the event
const signedEvent = await window.nostr.signEvent(restartEvent);
if (!signedEvent || !signedEvent.sig) {
throw new Error('Event signing failed');
}
// Publish via SimplePool
const url = relayConnectionUrl.value.trim();
const publishPromises = relayPool.publish([url], signedEvent);
// Use Promise.allSettled to capture per-relay outcomes
const results = await Promise.allSettled(publishPromises);
// Check if any relay accepted the event
let successCount = 0;
results.forEach((result, index) => {
if (result.status === 'fulfilled') {
successCount++;
log(`Restart command published successfully to relay ${index}`, 'INFO');
} else {
log(`Restart command failed on relay ${index}: ${result.reason?.message || result.reason}`, 'ERROR');
}
});
if (successCount === 0) {
const errorDetails = results.map((r, i) => `Relay ${i}: ${r.reason?.message || r.reason}`).join('; ');
throw new Error(`All relays rejected restart command. Details: ${errorDetails}`);
}
log('Restart command sent successfully - relay should restart shortly...', 'INFO');
// Update connection status to indicate restart is in progress
updateRelayConnectionStatus('connecting');
relayConnectionStatus.textContent = 'RESTARTING...';
// The relay will disconnect and need to be reconnected after restart
// This will be handled by the WebSocket disconnection event
} catch (error) {
log(`Failed to send restart command: ${error.message}`, 'ERROR');
updateRelayConnectionStatus('error');
}
}
// Send stats_query command to get database statistics using Administrator API (inner events)
async function sendStatsQuery() {
if (!isLoggedIn || !userPubkey) {
log('Must be logged in to query database statistics', 'ERROR');
updateStatsStatus('error', 'Not logged in');
return;
}
if (!relayPool) {
log('SimplePool connection not available', 'ERROR');
updateStatsStatus('error', 'No relay connection');
return;
}
try {
updateStatsStatus('loading', 'Querying database...');
// Create command array for stats query
const command_array = ["stats_query", "all"];
// Encrypt the command array directly using NIP-44
const encrypted_content = await encryptForRelay(JSON.stringify(command_array));
if (!encrypted_content) {
throw new Error('Failed to encrypt command array');
}
// Create single kind 23456 admin event
const statsEvent = {
kind: 23456,
pubkey: userPubkey,
created_at: Math.floor(Date.now() / 1000),
tags: [["p", getRelayPubkey()]],
content: encrypted_content
};
// Sign the event
const signedEvent = await window.nostr.signEvent(statsEvent);
if (!signedEvent || !signedEvent.sig) {
throw new Error('Event signing failed');
}
log('Sending stats query command...', 'INFO');
// Publish via SimplePool
const url = relayConnectionUrl.value.trim();
const publishPromises = relayPool.publish([url], signedEvent);
// Use Promise.allSettled to capture per-relay outcomes
const results = await Promise.allSettled(publishPromises);
// Check if any relay accepted the event
let successCount = 0;
results.forEach((result, index) => {
if (result.status === 'fulfilled') {
successCount++;
log(`Stats query published successfully to relay ${index}`, 'INFO');
} else {
log(`Stats query failed on relay ${index}: ${result.reason?.message || result.reason}`, 'ERROR');
}
});
if (successCount === 0) {
const errorDetails = results.map((r, i) => `Relay ${i}: ${r.reason?.message || r.reason}`).join('; ');
throw new Error(`All relays rejected stats query event. Details: ${errorDetails}`);
}
log('Stats query command sent successfully - waiting for response...', 'INFO');
updateStatsStatus('waiting', 'Waiting for response...');
} catch (error) {
log(`Failed to send stats query: ${error.message}`, 'ERROR');
updateStatsStatus('error', error.message);
}
}
// Handle stats_query response and populate tables
function handleStatsQueryResponse(responseData) {
try {
log('Processing stats query response...', 'INFO');
console.log('Stats response data:', responseData);
if (responseData.query_type !== 'stats_query') {
log('Ignoring non-stats response', 'WARNING');
return;
}
// Populate overview table
populateStatsOverview(responseData);
// Populate event kinds table
populateStatsKinds(responseData);
// Populate time-based statistics
populateStatsTime(responseData);
// Populate top pubkeys table
populateStatsPubkeys(responseData);
updateStatsStatus('loaded');
log('Database statistics updated successfully', 'INFO');
} catch (error) {
log(`Error processing stats response: ${error.message}`, 'ERROR');
updateStatsStatus('error', 'Failed to process response');
}
}
// Populate database overview table
function populateStatsOverview(data) {
if (!data) return;
// Update individual cells with flash animation for changed values
updateStatsCell('db-size', data.database_size_bytes ? formatFileSize(data.database_size_bytes) : '-');
updateStatsCell('total-events', data.total_events || '-');
updateStatsCell('oldest-event', data.database_created_at ? formatTimestamp(data.database_created_at) : '-');
updateStatsCell('newest-event', data.latest_event_at ? formatTimestamp(data.latest_event_at) : '-');
}
// Populate event kinds distribution table
function populateStatsKinds(data) {
const tableBody = document.getElementById('stats-kinds-table-body');
if (!tableBody || !data.event_kinds) return;
tableBody.innerHTML = '';
if (data.event_kinds.length === 0) {
const row = document.createElement('tr');
row.innerHTML = '<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);
}
});
// Set default relay URL based on where the page is being served from
function setDefaultRelayUrl() {
const relayUrlInput = document.getElementById('relay-connection-url');
if (!relayUrlInput) return;
// Get the current page's protocol and hostname
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const hostname = window.location.hostname;
const port = window.location.port;
// Construct the relay URL
let relayUrl;
if (hostname === 'localhost' || hostname === '127.0.0.1') {
// For localhost, default to ws://localhost:8888
relayUrl = 'ws://localhost:8888';
} else {
// For production, use the same hostname with WebSocket protocol
// Remove port from URL since relay typically runs on standard ports (80/443)
relayUrl = `${protocol}//${hostname}`;
}
relayUrlInput.value = relayUrl;
log(`Default relay URL set to: ${relayUrl}`, 'INFO');
}
// Initialize the app
document.addEventListener('DOMContentLoaded', () => {
console.log('C-Relay Admin API interface loaded');
// Set default relay URL based on current page location
setDefaultRelayUrl();
// Ensure admin sections are hidden by default on page load
updateAdminSectionsVisibility();
setTimeout(() => {
initializeApp();
// Enhance SimplePool for testing after initialization
setTimeout(enhancePoolForTesting, 2000);
}, 100);
});