Compare commits

...
7 Commits
14 changed files with 783 additions and 133 deletions
+8 -4
View File
@@ -112,6 +112,10 @@
<td>Process ID</td>
<td id="process-id">-</td>
</tr>
<tr>
<td>WebSocket Connections</td>
<td id="websocket-connections">-</td>
</tr>
<tr>
<td>Active Subscriptions</td>
<td id="active-subscriptions">-</td>
@@ -120,14 +124,14 @@
<td>Memory Usage</td>
<td id="memory-usage">-</td>
</tr>
<tr>
<td>CPU Core</td>
<td id="cpu-core">-</td>
</tr>
<tr>
<td>CPU Usage</td>
<td id="cpu-usage">-</td>
</tr>
<tr>
<td>CPU Core</td>
<td id="cpu-core">-</td>
</tr>
<tr>
<td>Oldest Event</td>
<td id="oldest-event">-</td>
+339 -114
View File
@@ -51,6 +51,12 @@ let pendingSqlQueries = new Map();
let eventRateChart = null;
let previousTotalEvents = 0; // Track previous total for rate calculation
// Temporary diagnostics toggle: disable external profile relay lookups
const DISABLE_EXTERNAL_PROFILE_RELAYS = false;
// Temporary diagnostics toggle: enable deep SimplePool diagnostics in nostr.bundle.js
window.__SIMPLE_POOL_DEBUG__ = true;
// Relay Events state - now handled by main subscription
// DOM elements
@@ -85,6 +91,47 @@ function log(message, type = 'INFO') {
// UI logging removed - using console only
}
function normalizeRelayUrlForPool(url) {
try {
let normalized = url || '';
if (!normalized.includes('://')) {
normalized = 'wss://' + normalized;
}
const parsed = new URL(normalized);
parsed.pathname = parsed.pathname.replace(/\/+/g, '/');
if (parsed.pathname.endsWith('/')) {
parsed.pathname = parsed.pathname.slice(0, -1);
}
if ((parsed.port === '80' && parsed.protocol === 'ws:') ||
(parsed.port === '443' && parsed.protocol === 'wss:')) {
parsed.port = '';
}
parsed.searchParams.sort();
parsed.hash = '';
return parsed.toString();
} catch (error) {
console.warn('Failed to normalize relay URL for diagnostics:', url, error?.message || error);
return url;
}
}
async function handleNip42Auth(url, challenge, context = 'SimplePool') {
console.log(`🔐 NIP-42 AUTH challenge during ${context}:`, challenge);
try {
if (!window.nostr) {
console.error(`NIP-42 auth required during ${context} but no nostr extension available`);
return;
}
const authEvent = window.NostrTools.nip42.makeAuthEvent(url, challenge);
const signedAuthEvent = await window.nostr.signEvent(authEvent);
console.log(`✅ NIP-42 AUTH signed for ${context}`);
return signedAuthEvent;
} catch (authError) {
console.error(`NIP-42 AUTH failed during ${context}:`, authError.message);
}
}
// Utility functions
function log(message, type = 'INFO') {
const timestamp = new Date().toISOString().split('T')[1].split('.')[0];
@@ -237,9 +284,38 @@ async function checkExistingAuthWithRetries() {
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;
// Extension auth must be actively restorable, otherwise treat as stale and continue.
if (authState.method === 'extension') {
console.log('Stored extension auth found, verifying live extension availability...');
let restoredExtensionAuth = null;
if (window.nostr && typeof window.nostr.restoreAuthState === 'function') {
restoredExtensionAuth = await window.nostr.restoreAuthState();
}
if (restoredExtensionAuth && restoredExtensionAuth.pubkey) {
console.log('✅ Extension auth restored successfully:', restoredExtensionAuth.pubkey);
console.log('✅ LOGIN ATTEMPT RESULT: SUCCESS (restored extension session)', {
method: 'extension',
pubkey: restoredExtensionAuth.pubkey
});
await restoreAuthenticationState(restoredExtensionAuth.pubkey);
return true;
}
console.log('⚠️ Stored extension auth is stale (no live extension), clearing and continuing');
if (typeof window.NOSTR_LOGIN_LITE.clearAuthState === 'function') {
window.NOSTR_LOGIN_LITE.clearAuthState();
}
} else {
console.log('✅ Auth state found via NOSTR_LOGIN_LITE.getAuthState():', authState.pubkey);
console.log('✅ LOGIN ATTEMPT RESULT: SUCCESS (restored NOSTR_LOGIN_LITE auth state)', {
method: authState.method || 'unknown',
pubkey: authState.pubkey
});
await restoreAuthenticationState(authState.pubkey);
return true;
}
}
}
@@ -249,6 +325,10 @@ async function checkExistingAuthWithRetries() {
const pubkey = await nlLite.getPublicKey();
if (pubkey && pubkey.length === 64) {
console.log('✅ Pubkey found via nlLite.getPublicKey():', pubkey);
console.log('✅ LOGIN ATTEMPT RESULT: SUCCESS (restored nlLite session)', {
method: 'restored-nlLite',
pubkey
});
await restoreAuthenticationState(pubkey);
return true;
}
@@ -260,6 +340,10 @@ async function checkExistingAuthWithRetries() {
const pubkey = await window.nostr.getPublicKey();
if (pubkey && pubkey.length === 64) {
console.log('✅ Pubkey found via window.nostr.getPublicKey():', pubkey);
console.log('✅ LOGIN ATTEMPT RESULT: SUCCESS (restored window.nostr session)', {
method: 'restored-window.nostr',
pubkey
});
await restoreAuthenticationState(pubkey);
return true;
}
@@ -272,6 +356,10 @@ async function checkExistingAuthWithRetries() {
const parsedData = JSON.parse(localStorageData);
if (parsedData.pubkey) {
console.log('✅ Pubkey found in localStorage:', parsedData.pubkey);
console.log('✅ LOGIN ATTEMPT RESULT: SUCCESS (restored localStorage session)', {
method: 'restored-localStorage',
pubkey: parsedData.pubkey
});
await restoreAuthenticationState(parsedData.pubkey);
return true;
}
@@ -295,6 +383,7 @@ async function checkExistingAuthWithRetries() {
}
}
console.warn('❌ LOGIN ATTEMPT RESULT: NO STORED SESSION FOUND (showing login modal)');
console.log('🔍 Authentication detection completed - no existing auth found after all attempts');
return false;
}
@@ -314,7 +403,12 @@ async function restoreAuthenticationState(pubkey) {
await setupAutomaticRelayConnection(false);
// Verify admin access before showing admin sections
verifyAdminAccess();
const adminCheckResult = await verifyAdminAccess('restore-auth-state');
if (adminCheckResult?.passed) {
console.log('✅ POST-LOGIN ADMIN CHECK PASSED (restore):', adminCheckResult.reason);
} else {
console.warn('❌ POST-LOGIN ADMIN CHECK FAILED (restore):', adminCheckResult?.reason || 'Unknown reason');
}
console.log('✅ Authentication state restored successfully');
}
@@ -447,7 +541,7 @@ async function initializeApp() {
}
// Handle authentication events
function handleAuthEvent(event) {
async function handleAuthEvent(event) {
const { pubkey, method, error } = event.detail;
if (method && pubkey) {
@@ -455,20 +549,34 @@ function handleAuthEvent(event) {
isLoggedIn = true;
console.log(`Login successful! Method: ${method}`);
console.log(`Public key: ${pubkey}`);
console.log('✅ LOGIN ATTEMPT RESULT: SUCCESS (interactive login)', { method, pubkey });
// Hide login modal and show profile in header
hideLoginModal();
showProfileInHeader();
loadUserProfile();
console.log('🔎 POST-LOGIN DEBUG: Starting admin verification flow', {
method,
pubkey,
isLoggedIn,
isRelayConnected
});
// Automatically set up relay connection
setupAutomaticRelayConnection(false); // Don't show sections yet - wait for admin verification
await setupAutomaticRelayConnection(false); // Don't show sections yet - wait for admin verification
// Verify admin access before showing admin sections
verifyAdminAccess();
const adminCheckResult = await verifyAdminAccess('auth-event');
if (adminCheckResult?.passed) {
console.log('✅ POST-LOGIN ADMIN CHECK PASSED:', adminCheckResult.reason);
} else {
console.warn('❌ POST-LOGIN ADMIN CHECK FAILED:', adminCheckResult?.reason || 'Unknown reason');
}
} else if (error) {
console.log(`Authentication error: ${error}`);
console.warn('❌ LOGIN ATTEMPT RESULT: FAILED (interactive login)', { error });
}
}
@@ -509,22 +617,40 @@ function handleLogoutEvent() {
}
// Verify admin access by sending a system_status command and waiting for response
async function verifyAdminAccess() {
console.log('=== VERIFYING ADMIN ACCESS (BYPASS MODE) ===');
async function verifyAdminAccess(source = 'unknown') {
console.log('=== VERIFYING ADMIN ACCESS (BYPASS MODE) ===', { source, isLoggedIn, isRelayConnected });
if (!isLoggedIn || !isRelayConnected) {
console.log('Cannot verify admin access - not logged in or not connected to relay');
showAccessDeniedOverlay();
return;
if (!isLoggedIn) {
const reason = 'User is not logged in';
console.warn('❌ Admin check failed:', reason, { source });
isAdminVerified = false;
pendingAdminVerification = false;
hideAccessDeniedOverlay();
updateAdminSectionsVisibility();
return { passed: false, reason, source };
}
if (!isRelayConnected) {
const reason = 'Relay connection is not ready yet';
console.warn('⚠️ Admin check deferred:', reason, { source });
pendingAdminVerification = false;
isAdminVerified = false;
hideAccessDeniedOverlay(); // Do not show ACCESS DENIED while connection is still establishing
updateAdminSectionsVisibility();
return { passed: false, reason, source, deferred: true };
}
// TEMPORARY BYPASS: Skip admin verification and grant access immediately
// TODO: Re-enable once idle_connection_timeout_sec is increased to 120+
pendingAdminVerification = false;
isAdminVerified = true;
hideAccessDeniedOverlay();
hideAdminVerificationLoading();
updateAdminSectionsVisibility();
console.log('✅ Admin verification bypassed (temporary)');
const reason = 'Bypass mode enabled (temporary)';
console.log('✅ Admin verification passed:', reason, { source });
return { passed: true, reason, source, bypass: true };
}
// Show loading indicator while verifying admin access
@@ -749,6 +875,20 @@ async function loadUserProfile() {
}
try {
if (DISABLE_EXTERNAL_PROFILE_RELAYS) {
console.warn('🧪 PROFILE RELAY TEST: External profile relays disabled; skipping profile query');
if (headerUserName) {
headerUserName.textContent = 'External profile lookup disabled (test mode)';
}
if (persistentUserName) {
persistentUserName.textContent = 'External profile lookup disabled (test mode)';
}
if (persistentUserAbout) {
persistentUserAbout.textContent = 'No external relay query performed';
}
return;
}
// Create a SimplePool instance for profile loading
const profilePool = new window.NostrTools.SimplePool();
const relays = ['wss://relay.damus.io',
@@ -975,40 +1115,17 @@ function attachWebSocketMonitoring(relayPool, url) {
// SimplePool stores connections in _conn object
if (relayPool && relayPool._conn) {
// Monitor when connections are created
const originalGetConnection = relayPool._conn[url];
if (originalGetConnection) {
// Diagnostics only: inspect existing connection without mutating _conn internals
const existingConn = relayPool._conn[url];
if (existingConn) {
console.log('📡 Found existing connection for URL:', url);
// Try to access the WebSocket if it's available
const conn = relayPool._conn[url];
if (conn && conn.ws) {
attachWebSocketEventListeners(conn.ws, url);
if (existingConn.ws && !existingConn.ws._monitored) {
attachWebSocketEventListeners(existingConn.ws, url);
existingConn.ws._monitored = true;
}
} else {
console.log('📡 No existing _conn entry yet for URL:', url);
}
// Override the connection getter to monitor new connections
const originalConn = relayPool._conn;
relayPool._conn = new Proxy(originalConn, {
get(target, prop) {
const conn = target[prop];
if (conn && conn.ws && !conn.ws._monitored) {
console.log('🔗 New WebSocket connection detected for:', prop);
attachWebSocketEventListeners(conn.ws, prop);
conn.ws._monitored = true;
}
return conn;
},
set(target, prop, value) {
if (value && value.ws && !value.ws._monitored) {
console.log('🔗 WebSocket connection being set for:', prop);
attachWebSocketEventListeners(value.ws, prop);
value.ws._monitored = true;
}
target[prop] = value;
return true;
}
});
}
console.log('✅ WebSocket monitoring attached');
@@ -1160,7 +1277,32 @@ async function subscribeToConfiguration() {
console.log(`📝 Generated subscription ID: ${subscriptionId}`);
console.log(`👤 User pubkey: ${userPubkey}`);
console.log(`🎯 About to call relayPool.subscribeMany with URL: ${url}`);
const normalizedUrl = normalizeRelayUrlForPool(url);
const relayMapKeysBefore = relayPool?.relays ? Array.from(relayPool.relays.keys()) : [];
console.log(`📊 relayPool._conn before subscribeMany:`, Object.keys(relayPool._conn || {}));
console.log('🔎 ADMIN SUB DIAG: URL normalization', {
rawUrl: url,
normalizedUrl,
relayMapKeysBefore
});
const preSubConn = relayPool._conn?.[url] || relayPool._conn?.[normalizedUrl];
const preSubRelay = relayPool?.relays?.get?.(normalizedUrl);
console.log('🔎 ADMIN SUB DIAG: pre-subscribe connection snapshot', {
url,
normalizedUrl,
hasConn: !!preSubConn,
connType: preSubConn?.constructor?.name,
hasWs: !!preSubConn?.ws,
wsReadyState: preSubConn?.ws?.readyState,
wsUrl: preSubConn?.ws?.url,
wsProtocol: preSubConn?.ws?.protocol,
hasRelayObj: !!preSubRelay,
relayConnected: preSubRelay?.connected,
isLoggedIn,
isRelayConnected,
isSubscribed,
isSubscribing
});
// Mark as subscribed BEFORE calling subscribeMany to prevent race conditions
isSubscribed = true;
@@ -1310,42 +1452,38 @@ async function subscribeToConfiguration() {
}
},
async onauth(challenge) {
// NIP-42 authentication: relay sent AUTH challenge, sign and respond
console.log('🔐 NIP-42 AUTH challenge received:', challenge);
try {
if (!window.nostr) {
console.error('NIP-42 auth required but no nostr extension available');
return;
}
const url = relayConnectionUrl.value.trim();
const authEvent = window.NostrTools.nip42.makeAuthEvent(url, challenge);
const signedAuthEvent = await window.nostr.signEvent(authEvent);
console.log('✅ NIP-42 AUTH event signed, responding to challenge');
return signedAuthEvent;
} catch (authError) {
console.error('NIP-42 AUTH failed:', authError.message);
}
const authUrl = relayConnectionUrl.value.trim();
return handleNip42Auth(authUrl, challenge, 'subscription');
},
onclose(reason) {
console.log('Subscription closed:', reason);
// Only reset state if the admin relay itself closed (not external relays timing out)
// reason is an array of close reasons, one per relay in the subscription
const adminUrl = relayConnectionUrl.value.trim();
const closeNormalizedUrl = normalizeRelayUrlForPool(url);
const closeConn = relayPool?._conn?.[url] || relayPool?._conn?.[closeNormalizedUrl];
const closeRelay = relayPool?.relays?.get?.(closeNormalizedUrl);
const reasons = Array.isArray(reason) ? reason : [reason];
// Check if the admin relay's connection closed (not just external relays timing out)
// If all reasons are 'connection timed out', these are external relays, not our relay
const allTimeout = reasons.every(r => r === 'connection timed out');
if (!allTimeout) {
// Admin relay actually closed — reset state
isSubscribed = false;
isSubscribing = false;
isRelayConnected = false;
updateConfigStatus(false);
log('WebSocket connection closed - subscription state reset', 'WARNING');
} else {
// External relays timed out — keep admin relay state intact
log('External relay connections timed out (admin relay still connected)', 'INFO');
}
console.log('Subscription closed:', reason);
console.log('🔎 ADMIN SUB DIAG: onclose details', {
url,
closeNormalizedUrl,
reasons,
allTimeout: reasons.length > 0 && reasons.every(r => String(r).includes('timeout')),
hasConn: !!closeConn,
hasWs: !!closeConn?.ws,
wsReadyState: closeConn?.ws?.readyState,
wsUrl: closeConn?.ws?.url,
hasRelayObj: !!closeRelay,
relayConnected: closeRelay?.connected,
isLoggedIn,
isRelayConnected,
isSubscribed,
isSubscribing
});
// This subscription is created only for the admin relay URL in subscribeMany([url], ...),
// so any close/timeout here must reset admin relay state to force clean reconnect.
isSubscribed = false;
isSubscribing = false;
isRelayConnected = false;
updateConfigStatus(false);
log('Admin relay subscription closed/timed out - connection state reset', 'WARNING');
}
});
@@ -1356,6 +1494,27 @@ async function subscribeToConfiguration() {
isSubscribed = true;
isSubscribing = false;
const normalizedUrlAfter = normalizeRelayUrlForPool(url);
const relayMapKeysAfter = relayPool?.relays ? Array.from(relayPool.relays.keys()) : [];
const postSubConn = relayPool._conn?.[url] || relayPool._conn?.[normalizedUrlAfter];
const postSubRelay = relayPool?.relays?.get?.(normalizedUrlAfter);
console.log('🔎 ADMIN SUB DIAG: post-subscribe connection snapshot', {
url,
normalizedUrl: normalizedUrlAfter,
relayMapKeysAfter,
hasConn: !!postSubConn,
connType: postSubConn?.constructor?.name,
hasWs: !!postSubConn?.ws,
wsReadyState: postSubConn?.ws?.readyState,
wsUrl: postSubConn?.ws?.url,
wsProtocol: postSubConn?.ws?.protocol,
hasRelayObj: !!postSubRelay,
relayConnected: postSubRelay?.connected,
isLoggedIn,
isRelayConnected,
isSubscribed,
isSubscribing
});
console.log('✅ Subscription established successfully');
return true;
@@ -1479,9 +1638,9 @@ function initializeEventRateChart() {
debug: false // Disable debug logging
});
console.log('ASCIIBarChart instance created:', eventRateChart);
console.log('Chart container content after init:', chartContainer.textContent);
console.log('Chart container innerHTML after init:', chartContainer.innerHTML);
// console.log('ASCIIBarChart instance created:', eventRateChart);
// console.log('Chart container content after init:', chartContainer.textContent);
// console.log('Chart container innerHTML after init:', chartContainer.innerHTML);
// Force an initial render
if (eventRateChart && typeof eventRateChart.render === 'function') {
@@ -1965,7 +2124,7 @@ async function loadWotStatus() {
// Publish via SimplePool
const url = relayConnectionUrl.value.trim();
await relayPool.publish([url], signedEvent);
await relayPool.publish([url], signedEvent, { onauth: (challenge) => handleNip42Auth(url, challenge, 'publish') });
log('WoT status query sent successfully', 'INFO');
@@ -2011,7 +2170,7 @@ async function setWotLevel(level) {
// Publish via SimplePool
const url = relayConnectionUrl.value.trim();
await relayPool.publish([url], signedEvent);
await relayPool.publish([url], signedEvent, { onauth: (challenge) => handleNip42Auth(url, challenge, 'publish') });
log(`WoT level set to ${level} - sync will trigger automatically`, 'INFO');
@@ -2060,7 +2219,7 @@ async function syncWot() {
// Publish via SimplePool
const url = relayConnectionUrl.value.trim();
await relayPool.publish([url], signedEvent);
await relayPool.publish([url], signedEvent, { onauth: (challenge) => handleNip42Auth(url, challenge, 'publish') });
log('WoT sync command sent', 'INFO');
@@ -2243,7 +2402,7 @@ async function fetchConfiguration() {
// Publish via SimplePool with detailed error diagnostics
const url = relayConnectionUrl.value.trim();
const publishPromises = relayPool.publish([url], signedEvent);
const publishPromises = relayPool.publish([url], signedEvent, { onauth: (challenge) => handleNip42Auth(url, challenge, 'publish') });
// Use Promise.allSettled to capture per-relay outcomes instead of Promise.any
const results = await Promise.allSettled(publishPromises);
@@ -2268,6 +2427,13 @@ async function fetchConfiguration() {
// Throw error if all relays failed
if (successCount === 0) {
const errorDetails = results.map((r, i) => `Relay ${i}: ${r.reason?.message || r.reason}`).join('; ');
console.warn('❌ ADMIN COMMAND RESULT: FAILED (config_query)', {
relayUrl: url,
userPubkey,
relayPubkey,
reason: errorDetails,
hint: 'Login can be successful while admin commands fail (wrong admin key, relay-side auth rejection, or command timeout).'
});
throw new Error(`All relays rejected the event. Details: ${errorDetails}`);
}
@@ -2281,6 +2447,11 @@ async function fetchConfiguration() {
} catch (error) {
console.error('Failed to fetch configuration:', error);
console.warn('❌ ADMIN COMMAND RESULT: FAILED (fetchConfiguration catch)', {
userPubkey,
relayPubkey,
message: error?.message || String(error)
});
return false;
}
}
@@ -2488,7 +2659,7 @@ async function sendConfigUpdateCommand(configObjects) {
// Publish via SimplePool with detailed error diagnostics
const url = relayConnectionUrl.value.trim();
const publishPromises = relayPool.publish([url], signedEvent);
const publishPromises = relayPool.publish([url], signedEvent, { onauth: (challenge) => handleNip42Auth(url, challenge, 'publish') });
// Use Promise.allSettled to capture per-relay outcomes instead of Promise.any
const results = await Promise.allSettled(publishPromises);
@@ -2684,7 +2855,7 @@ async function loadAuthRules() {
// Publish via SimplePool with detailed error diagnostics
const url = relayConnectionUrl.value.trim();
const publishPromises = relayPool.publish([url], signedEvent);
const publishPromises = relayPool.publish([url], signedEvent, { onauth: (challenge) => handleNip42Auth(url, challenge, 'publish') });
// Use Promise.allSettled to capture per-relay outcomes instead of Promise.any
const results = await Promise.allSettled(publishPromises);
@@ -2901,7 +3072,7 @@ async function deleteAuthRule(index) {
// Publish via SimplePool with detailed error diagnostics
const url = relayConnectionUrl.value.trim();
const publishPromises = relayPool.publish([url], signedEvent);
const publishPromises = relayPool.publish([url], signedEvent, { onauth: (challenge) => handleNip42Auth(url, challenge, 'publish') });
// Use Promise.allSettled to capture per-relay outcomes instead of Promise.any
const results = await Promise.allSettled(publishPromises);
@@ -3329,7 +3500,7 @@ async function addAuthRuleViaWebSocket(ruleData) {
// Publish via SimplePool with detailed error diagnostics
const url = relayConnectionUrl.value.trim();
const publishPromises = relayPool.publish([url], signedEvent);
const publishPromises = relayPool.publish([url], signedEvent, { onauth: (challenge) => handleNip42Auth(url, challenge, 'publish') });
// Use Promise.allSettled to capture per-relay outcomes instead of Promise.any
const results = await Promise.allSettled(publishPromises);
@@ -3435,7 +3606,7 @@ async function testGetAuthRules() {
// Publish via SimplePool with detailed error diagnostics
const url = relayConnectionUrl.value.trim();
const publishPromises = relayPool.publish([url], signedEvent);
const publishPromises = relayPool.publish([url], signedEvent, { onauth: (challenge) => handleNip42Auth(url, challenge, 'publish') });
// Use Promise.allSettled to capture per-relay outcomes instead of Promise.any
const results = await Promise.allSettled(publishPromises);
@@ -3509,7 +3680,7 @@ async function testClearAuthRules() {
// Publish via SimplePool with detailed error diagnostics
const url = relayConnectionUrl.value.trim();
const publishPromises = relayPool.publish([url], signedEvent);
const publishPromises = relayPool.publish([url], signedEvent, { onauth: (challenge) => handleNip42Auth(url, challenge, 'publish') });
// Use Promise.allSettled to capture per-relay outcomes instead of Promise.any
const results = await Promise.allSettled(publishPromises);
@@ -3592,7 +3763,7 @@ async function testAddBlacklist() {
// Publish via SimplePool with detailed error diagnostics
const url = relayConnectionUrl.value.trim();
const publishPromises = relayPool.publish([url], signedEvent);
const publishPromises = relayPool.publish([url], signedEvent, { onauth: (challenge) => handleNip42Auth(url, challenge, 'publish') });
// Use Promise.allSettled to capture per-relay outcomes instead of Promise.any
const results = await Promise.allSettled(publishPromises);
@@ -3675,7 +3846,7 @@ async function testAddWhitelist() {
// Publish via SimplePool
const url = relayConnectionUrl.value.trim();
const publishPromises = relayPool.publish([url], signedEvent);
const publishPromises = relayPool.publish([url], signedEvent, { onauth: (challenge) => handleNip42Auth(url, challenge, 'publish') });
// Use Promise.allSettled to capture per-relay outcomes instead of Promise.any
const results = await Promise.allSettled(publishPromises);
@@ -3749,7 +3920,7 @@ async function testConfigQuery() {
// Publish via SimplePool with detailed error diagnostics
const url = relayUrl.value.trim();
const publishPromises = relayPool.publish([url], signedEvent);
const publishPromises = relayPool.publish([url], signedEvent, { onauth: (challenge) => handleNip42Auth(url, challenge, 'publish') });
// Use Promise.allSettled to capture per-relay outcomes instead of Promise.any
const results = await Promise.allSettled(publishPromises);
@@ -3819,7 +3990,7 @@ async function testPostEvent() {
const url = relayConnectionUrl.value.trim();
logTestEvent('INFO', `Publishing to relay: ${url}`, 'INFO');
const publishPromises = relayPool.publish([url], signedEvent);
const publishPromises = relayPool.publish([url], signedEvent, { onauth: (challenge) => handleNip42Auth(url, challenge, 'publish') });
// Use Promise.allSettled to capture per-relay outcomes instead of Promise.any
const results = await Promise.allSettled(publishPromises);
@@ -3997,7 +4168,7 @@ async function sendNIP17DM() {
// Publish via SimplePool
const url = relayConnectionUrl.value.trim();
const publishPromises = relayPool.publish([url], signedGiftWrap);
const publishPromises = relayPool.publish([url], signedGiftWrap, { onauth: (challenge) => handleNip42Auth(url, challenge, 'nip17 dm publish') });
// Use Promise.allSettled to capture per-relay outcomes
const results = await Promise.allSettled(publishPromises);
@@ -4235,7 +4406,7 @@ async function sendRestartCommand() {
// Publish via SimplePool
const url = relayConnectionUrl.value.trim();
const publishPromises = relayPool.publish([url], signedEvent);
const publishPromises = relayPool.publish([url], signedEvent, { onauth: (challenge) => handleNip42Auth(url, challenge, 'publish') });
// Use Promise.allSettled to capture per-relay outcomes
const results = await Promise.allSettled(publishPromises);
@@ -4286,6 +4457,12 @@ async function sendStatsQuery() {
}
try {
// Ensure admin subscription/connection is alive before publishing stats command
const subscriptionReady = await subscribeToConfiguration();
if (!subscriptionReady) {
throw new Error('Admin relay subscription is not ready (connection may have timed out)');
}
updateStatsStatus('loading', 'Querying database...');
// Create command array for stats query
@@ -4316,7 +4493,28 @@ async function sendStatsQuery() {
// Publish via SimplePool
const url = relayConnectionUrl.value.trim();
const publishPromises = relayPool.publish([url], signedEvent);
const normalizedUrl = normalizeRelayUrlForPool(url);
const relayMapKeysBeforePublish = relayPool?.relays ? Array.from(relayPool.relays.keys()) : [];
const prePublishConn = relayPool._conn?.[url] || relayPool._conn?.[normalizedUrl];
const prePublishRelay = relayPool?.relays?.get?.(normalizedUrl);
console.log('🔎 ADMIN PUBLISH DIAG: pre-publish connection snapshot', {
url,
normalizedUrl,
relayMapKeysBeforePublish,
hasConn: !!prePublishConn,
connType: prePublishConn?.constructor?.name,
hasWs: !!prePublishConn?.ws,
wsReadyState: prePublishConn?.ws?.readyState,
wsUrl: prePublishConn?.ws?.url,
wsProtocol: prePublishConn?.ws?.protocol,
hasRelayObj: !!prePublishRelay,
relayConnected: prePublishRelay?.connected,
isLoggedIn,
isRelayConnected,
isSubscribed,
isSubscribing
});
const publishPromises = relayPool.publish([url], signedEvent, { onauth: (challenge) => handleNip42Auth(url, challenge, 'publish') });
// Use Promise.allSettled to capture per-relay outcomes
const results = await Promise.allSettled(publishPromises);
@@ -4334,6 +4532,13 @@ async function sendStatsQuery() {
if (successCount === 0) {
const errorDetails = results.map((r, i) => `Relay ${i}: ${r.reason?.message || r.reason}`).join('; ');
console.warn('❌ ADMIN COMMAND RESULT: FAILED (stats_query)', {
relayUrl: url,
userPubkey,
relayPubkey,
reason: errorDetails,
hint: 'Login can be successful while admin commands fail (wrong admin key, relay-side auth rejection, or command timeout).'
});
throw new Error(`All relays rejected stats query event. Details: ${errorDetails}`);
}
@@ -4342,6 +4547,11 @@ async function sendStatsQuery() {
} catch (error) {
log(`Failed to send stats query: ${error.message}`, 'ERROR');
console.warn('❌ ADMIN COMMAND RESULT: FAILED (sendStatsQuery catch)', {
userPubkey,
relayPubkey,
message: error?.message || String(error)
});
updateStatsStatus('error', error.message);
}
}
@@ -4492,20 +4702,38 @@ function updateStatsFromCpuMonitoringEvent(monitoringData) {
if (monitoringData.process_id !== undefined) {
updateStatsCell('process-id', monitoringData.process_id.toString());
}
if (monitoringData.active_connections !== undefined) {
updateStatsCell('websocket-connections', monitoringData.active_connections.toString());
}
if (monitoringData.memory_usage_mb !== undefined) {
updateStatsCell('memory-usage', monitoringData.memory_usage_mb.toFixed(1) + ' MB');
}
// MEM% bar using memory_percent from server
if (monitoringData.memory_percent !== undefined) {
const memPct = Math.min(100, Math.max(0, monitoringData.memory_percent));
updateStatsCell('memory-usage', makeAsciiBar(memPct) + ' ' + monitoringData.memory_usage_mb.toFixed(1) + ' MB');
}
if (monitoringData.current_cpu_core !== undefined) {
updateStatsCell('cpu-core', 'Core ' + monitoringData.current_cpu_core);
}
// Calculate CPU usage percentage if we have the data
// CPU% using delta between samples
if (monitoringData.process_cpu_time !== undefined && monitoringData.system_cpu_time !== undefined) {
// For now, just show the raw process CPU time (simplified)
// In a real implementation, you'd calculate deltas over time
updateStatsCell('cpu-usage', monitoringData.process_cpu_time + ' ticks');
if (window._prevCpuSample) {
const procDelta = monitoringData.process_cpu_time - window._prevCpuSample.proc;
const sysDelta = monitoringData.system_cpu_time - window._prevCpuSample.sys;
if (sysDelta > 0) {
const cpuPct = Math.min(100, Math.max(0, (procDelta / sysDelta) * 100));
updateStatsCell('cpu-usage', makeAsciiBar(cpuPct));
}
}
window._prevCpuSample = {
proc: monitoringData.process_cpu_time,
sys: monitoringData.system_cpu_time
};
}
} catch (error) {
@@ -4895,6 +5123,13 @@ function formatTimestamp(timestamp) {
return date.toLocaleString();
}
// Generate ASCII progress bar: [###############.....] 75%
function makeAsciiBar(pct) {
const filled = Math.round(pct / 5); // 20 chars = 100%
const empty = 20 - filled;
return '[' + '#'.repeat(filled) + '.'.repeat(empty) + '] ' + pct.toFixed(1) + '%';
}
// Update statistics cell with flash animation if value changed
function updateStatsCell(cellId, newValue) {
const cell = document.getElementById(cellId);
@@ -5474,17 +5709,7 @@ async function sendAdminCommand(commandArray) {
// Publish via SimplePool with NIP-42 auth support
const url = relayConnectionUrl.value.trim();
const publishPromises = relayPool.publish([url], signedEvent, {
async onauth(challenge) {
console.log('🔐 NIP-42 AUTH challenge during publish:', challenge);
try {
const authEvent = window.NostrTools.nip42.makeAuthEvent(url, challenge);
const signedAuthEvent = await window.nostr.signEvent(authEvent);
console.log('✅ NIP-42 AUTH signed for publish');
return signedAuthEvent;
} catch (authError) {
console.error('NIP-42 AUTH failed during publish:', authError.message);
}
}
onauth: (challenge) => handleNip42Auth(url, challenge, 'admin publish')
});
// Use Promise.allSettled to capture per-relay outcomes
@@ -6042,7 +6267,7 @@ async function sendCreateRelayEventCommand(kind, eventData) {
// Publish via SimplePool
const url = relayConnectionUrl.value.trim();
const publishPromises = relayPool.publish([url], signedEvent);
const publishPromises = relayPool.publish([url], signedEvent, { onauth: (challenge) => handleNip42Auth(url, challenge, 'publish') });
// Wait for publish results
const results = await Promise.allSettled(publishPromises);
+95 -1
View File
@@ -3297,8 +3297,18 @@ var NostrTools = (() => {
this.enablePing = opts.enablePing;
}
async ensureRelay(url, params) {
const rawUrl = url;
const debugEnabled = typeof window !== "undefined" && !!window.__SIMPLE_POOL_DEBUG__;
url = normalizeURL(url);
let relay = this.relays.get(url);
if (debugEnabled) {
console.log("🔎 SIMPLE_POOL ensureRelay START", {
rawUrl,
normalizedUrl: url,
hadRelay: !!relay,
relayMapKeysBefore: Array.from(this.relays.keys())
});
}
if (!relay) {
relay = new AbstractRelay(url, {
verifyEvent: this.trustedRelayURLs.has(url) ? alwaysTrue : this.verifyEvent,
@@ -3306,13 +3316,38 @@ var NostrTools = (() => {
enablePing: this.enablePing
});
relay.onclose = () => {
if (debugEnabled) {
console.log("🔎 SIMPLE_POOL ensureRelay onclose", {
normalizedUrl: url,
relayMapKeysBeforeDelete: Array.from(this.relays.keys())
});
}
this.relays.delete(url);
if (debugEnabled) {
console.log("🔎 SIMPLE_POOL ensureRelay onclose complete", {
normalizedUrl: url,
relayMapKeysAfterDelete: Array.from(this.relays.keys())
});
}
};
if (params?.connectionTimeout)
relay.connectionTimeout = params.connectionTimeout;
this.relays.set(url, relay);
if (debugEnabled) {
console.log("🔎 SIMPLE_POOL ensureRelay created relay", {
normalizedUrl: url,
relayMapKeysAfterCreate: Array.from(this.relays.keys())
});
}
}
await relay.connect();
if (debugEnabled) {
console.log("🔎 SIMPLE_POOL ensureRelay connected", {
normalizedUrl: url,
relayConnected: relay.connected,
relayMapKeysAfterConnect: Array.from(this.relays.keys())
});
}
return relay;
}
close(relays) {
@@ -3334,16 +3369,26 @@ var NostrTools = (() => {
}
subscribeMany(relays, filters, params) {
params.onauth = params.onauth || params.doauth;
const debugEnabled = typeof window !== "undefined" && !!window.__SIMPLE_POOL_DEBUG__;
const request = [];
const uniqUrls = [];
for (let i2 = 0; i2 < relays.length; i2++) {
const url = normalizeURL(relays[i2]);
if (uniqUrls.indexOf(url) === -1) {
uniqUrls.push(url);
for (let f2 = 0; f2 < filters.length; f2++) {
request.push({ url, filter: filters[f2] });
}
}
}
if (debugEnabled) {
console.log("🔎 SIMPLE_POOL subscribeMany", {
relaysInput: relays,
uniqUrls,
filtersCount: filters.length,
requestsCount: request.length
});
}
return this.subscribeMap(request, params);
}
subscribeMap(requests, params) {
@@ -3391,14 +3436,31 @@ var NostrTools = (() => {
_knownIds.add(id);
return have;
};
const debugEnabled = typeof window !== "undefined" && !!window.__SIMPLE_POOL_DEBUG__;
const allOpened = Promise.all(
requests.map(async ({ url, filter }, i2) => {
if (debugEnabled) {
console.log("🔎 SIMPLE_POOL subscribeMap request", {
index: i2,
url,
filterKinds: filter?.kinds,
hasAuthorFilter: !!filter?.authors,
hasPTagFilter: !!filter?.["#p"]
});
}
let relay;
try {
relay = await this.ensureRelay(url, {
connectionTimeout: params.maxWait ? Math.max(params.maxWait * 0.8, params.maxWait - 1e3) : void 0
});
} catch (err) {
if (debugEnabled) {
console.log("🔎 SIMPLE_POOL subscribeMap ensureRelay FAILED", {
index: i2,
url,
error: err?.message || String(err)
});
}
handleClose(i2, err?.message || String(err));
return;
}
@@ -3480,18 +3542,50 @@ var NostrTools = (() => {
return events[0] || null;
}
publish(relays, event, options) {
return relays.map(normalizeURL).map(async (url, i2, arr) => {
const debugEnabled = typeof window !== "undefined" && !!window.__SIMPLE_POOL_DEBUG__;
const normalizedRelays = relays.map(normalizeURL);
if (debugEnabled) {
console.log("🔎 SIMPLE_POOL publish START", {
relaysInput: relays,
normalizedRelays,
eventKind: event?.kind,
eventId: event?.id
});
}
return normalizedRelays.map(async (url, i2, arr) => {
if (arr.indexOf(url) !== i2) {
if (debugEnabled) {
console.log("🔎 SIMPLE_POOL publish duplicate URL", { url, index: i2 });
}
return Promise.reject("duplicate url");
}
let r = await this.ensureRelay(url);
if (debugEnabled) {
console.log("🔎 SIMPLE_POOL publish ensured relay", {
url,
relayConnected: r?.connected,
relayMapKeys: Array.from(this.relays.keys())
});
}
return r.publish(event).catch(async (err) => {
if (debugEnabled) {
console.log("🔎 SIMPLE_POOL publish ERROR", {
url,
error: err?.message || String(err)
});
}
if (err instanceof Error && err.message.startsWith("auth-required: ") && options?.onauth) {
await r.auth(options.onauth);
return r.publish(event);
}
throw err;
}).then((reason) => {
if (debugEnabled) {
console.log("🔎 SIMPLE_POOL publish SUCCESS", {
url,
reason
});
}
if (this.trackRelays) {
let set = this.seenOn.get(event.id);
if (!set) {
+1
View File
@@ -0,0 +1 @@
key,value,data_type,description,category,requires_restart,created_at,updated_at
1 key value data_type description category requires_restart created_at updated_at
+1 -1
View File
@@ -1 +1 @@
2380224
2946182
+25 -2
View File
@@ -13,6 +13,7 @@ extern void log_query_execution(const char* query_type, const char* sub_id,
#include <pthread.h>
#include <libwebsockets.h>
#include <cjson/cJSON.h>
int get_active_connection_count(void);
#include <sqlite3.h>
#include <time.h>
#include <sys/stat.h>
@@ -1291,13 +1292,16 @@ cJSON* query_cpu_metrics(void) {
pid_t pid = getpid();
cJSON_AddNumberToObject(cpu_stats, "process_id", (double)pid);
// Get memory usage from /proc/self/status
// Get active WebSocket connection count
cJSON_AddNumberToObject(cpu_stats, "active_connections", (double)get_active_connection_count());
// Get memory usage from /proc/self/status and calculate MEM%
unsigned long rss_kb = 0;
FILE* mem_stat = fopen("/proc/self/status", "r");
if (mem_stat) {
char line[256];
while (fgets(line, sizeof(line), mem_stat)) {
if (strncmp(line, "VmRSS:", 6) == 0) {
unsigned long rss_kb;
if (sscanf(line, "VmRSS: %lu kB", &rss_kb) == 1) {
double rss_mb = rss_kb / 1024.0;
cJSON_AddNumberToObject(cpu_stats, "memory_usage_mb", rss_mb);
@@ -1308,6 +1312,25 @@ cJSON* query_cpu_metrics(void) {
fclose(mem_stat);
}
// Get total system memory from /proc/meminfo for MEM%
if (rss_kb > 0) {
FILE* meminfo = fopen("/proc/meminfo", "r");
if (meminfo) {
char line[256];
while (fgets(line, sizeof(line), meminfo)) {
if (strncmp(line, "MemTotal:", 9) == 0) {
unsigned long total_kb;
if (sscanf(line, "MemTotal: %lu kB", &total_kb) == 1 && total_kb > 0) {
double mem_pct = (rss_kb * 100.0) / total_kb;
cJSON_AddNumberToObject(cpu_stats, "memory_percent", mem_pct);
}
break;
}
}
fclose(meminfo);
}
}
return cpu_stats;
}
+2 -2
View File
@@ -101,8 +101,8 @@ static const struct {
// Idle Connection Ban Settings
// Ban IPs that connect but never send REQ or EVENT (idle or early disconnect)
{"idle_connection_timeout_sec", "30"}, // Seconds before idle connection is closed (0 = disabled)
{"idle_ban_enabled", "true"}, // Whether to ban IPs with idle failures
{"idle_connection_timeout_sec", "0"}, // Seconds before idle connection is closed (0 = disabled)
{"idle_ban_enabled", "false"}, // Whether to ban IPs with idle failures
{"idle_ban_threshold", "1"}, // Idle failures before ban (1 = ban on first offense)
{"idle_ban_window_sec", "30"}, // Window to count idle failures in
{"idle_ban_duration_sec", "300"}, // Initial ban duration (doubles each time, max 24h)
File diff suppressed because one or more lines are too long
+7
View File
@@ -1260,6 +1260,13 @@ int handle_req_message(const char* sub_id, cJSON* filters, struct lws *wsi, stru
continue;
}
// NIP-01: limit:0 means return zero stored events — skip SQL entirely, just send EOSE
cJSON* limit_check = cJSON_GetObjectItemCaseSensitive(filter, "limit");
if (limit_check && cJSON_IsNumber(limit_check) && (int)cJSON_GetNumberValue(limit_check) == 0) {
DEBUG_LOG("Filter has limit:0 — skipping query, sending EOSE immediately (NIP-01 compliance)");
continue;
}
// Reset bind params for this filter
free_bind_params(bind_params, bind_param_count);
bind_params = NULL;
+2 -2
View File
@@ -13,8 +13,8 @@
// Using CRELAY_ prefix to avoid conflicts with nostr_core_lib VERSION macros
#define CRELAY_VERSION_MAJOR 1
#define CRELAY_VERSION_MINOR 2
#define CRELAY_VERSION_PATCH 44
#define CRELAY_VERSION "v1.2.44"
#define CRELAY_VERSION_PATCH 51
#define CRELAY_VERSION "v1.2.51"
// Relay metadata (authoritative source for NIP-11 information)
#define RELAY_NAME "C-Relay"
+11 -1
View File
@@ -12,6 +12,7 @@
#include <string.h>
#include <stdlib.h>
#include <time.h>
#include <stdio.h>
#include "websockets.h"
#include "ip_ban.h"
@@ -94,8 +95,17 @@ void handle_nip42_auth_signed_event(struct lws* wsi, struct per_session_data* ps
// Verify authentication using existing request_validator function
// Note: nostr_nip42_verify_auth_event doesn't extract pubkey, we need to do that separately
char relay_url[RELAY_URL_MAX_LENGTH];
const char* configured_relay_url = get_config_value("relay_url");
if (configured_relay_url && configured_relay_url[0] != '\0') {
snprintf(relay_url, sizeof(relay_url), "%s", configured_relay_url);
} else {
int relay_port = (g_relay_port > 0) ? g_relay_port : get_config_int("relay_port", DEFAULT_PORT);
snprintf(relay_url, sizeof(relay_url), "ws://127.0.0.1:%d", relay_port);
}
int result = nostr_nip42_verify_auth_event(auth_event, challenge_copy,
"ws://localhost:8888", 600); // 10 minutes tolerance
relay_url, 600); // 10 minutes tolerance
char authenticated_pubkey[65] = {0};
if (result == 0) {
+8
View File
@@ -116,6 +116,9 @@ extern struct lws_context *ws_context;
// Global subscription manager
struct subscription_manager g_subscription_manager;
// Actual relay port bound by libwebsockets at runtime (after fallback/retry logic)
int g_relay_port = DEFAULT_PORT;
// Global connection list for idle connection tracking
// Tracks ALL WebSocket connections (not just subscribed ones)
// so the periodic timer can find and close idle connections
@@ -130,6 +133,8 @@ static tracked_connection_t g_connections[MAX_TRACKED_CONNECTIONS];
static int g_connection_count = 0;
static pthread_mutex_t g_connections_lock = PTHREAD_MUTEX_INITIALIZER;
int get_active_connection_count(void) { return g_connection_count; }
static void connection_list_add(struct lws* wsi, struct per_session_data* pss) {
pthread_mutex_lock(&g_connections_lock);
for (int i = 0; i < MAX_TRACKED_CONNECTIONS; i++) {
@@ -2607,6 +2612,9 @@ int start_websocket_relay(int port_override, int strict_port) {
return -1;
}
// Persist the actual bound port for components that need the runtime relay URL
g_relay_port = actual_port;
char startup_msg[256];
if (actual_port != configured_port) {
snprintf(startup_msg, sizeof(startup_msg),
+6
View File
@@ -96,6 +96,12 @@ struct per_session_data {
int is_websocket; // 1 if this is a WebSocket connection, 0 for HTTP
};
// Get current active WebSocket connection count
int get_active_connection_count(void);
// Actual relay port bound by libwebsockets at runtime (after fallback/retry logic)
extern int g_relay_port;
// NIP-11 HTTP session data structure for managing buffer lifetime
struct nip11_session_data {
int type; // 0 for NIP-11
+272
View File
@@ -0,0 +1,272 @@
#!/usr/bin/env node
import WebSocket from 'ws';
import {
finalizeEvent,
generateSecretKey,
getPublicKey,
nip19,
nip42,
} from 'nostr-tools';
const RELAY_URL = process.env.RELAY_URL || 'ws://127.0.0.1:7777';
const EVENT_COUNT = Number(process.env.EVENT_COUNT || 5);
const DELAY_MS = Number(process.env.DELAY_MS || 2000);
const RECIPIENT_PUBKEY_ENV = process.env.RECIPIENT_PUBKEY || '';
const SECRET_INPUT = process.env.NOSTR_SECRET_KEY || '';
const OVERALL_TIMEOUT_MS = Number(process.env.OVERALL_TIMEOUT_MS || 120000);
function now() {
return new Date().toISOString();
}
function log(msg, extra = null) {
if (extra !== null) {
console.log(`[${now()}] ${msg}`, extra);
} else {
console.log(`[${now()}] ${msg}`);
}
}
function normalizeHexSecret(secretInput) {
if (!secretInput) return null;
const raw = secretInput.trim();
if (/^[0-9a-fA-F]{64}$/.test(raw)) {
return raw.toLowerCase();
}
if (raw.startsWith('nsec1')) {
const decoded = nip19.decode(raw);
if (decoded.type !== 'nsec') {
throw new Error('NOSTR_SECRET_KEY is nsec-like but did not decode as nsec');
}
if (typeof decoded.data === 'string') {
return decoded.data.toLowerCase();
}
if (decoded.data instanceof Uint8Array) {
return Buffer.from(decoded.data).toString('hex').toLowerCase();
}
throw new Error('Unsupported nsec decoded payload type');
}
throw new Error('NOSTR_SECRET_KEY must be 64-char hex or nsec1...');
}
const hexSecret = normalizeHexSecret(SECRET_INPUT) || Buffer.from(generateSecretKey()).toString('hex');
const secretKey = Buffer.from(hexSecret, 'hex');
const senderPubkey = getPublicKey(secretKey);
const recipientPubkey = RECIPIENT_PUBKEY_ENV || senderPubkey;
let ws;
let currentChallenge = null;
let authenticated = false;
let authAttempts = 0;
let sentAccepted = 0;
let nextSeq = 1;
let pendingEvent = null;
let waitingForAuth = false;
let finished = false;
let overallTimer = null;
function makeKind4Event(seq) {
const eventTemplate = {
kind: 4,
created_at: Math.floor(Date.now() / 1000),
tags: [['p', recipientPubkey]],
content: `kind4 disconnect probe seq=${seq} ts=${Date.now()}`,
pubkey: senderPubkey,
};
return finalizeEvent(eventTemplate, secretKey);
}
function sendJson(arr) {
const payload = JSON.stringify(arr);
ws.send(payload);
}
function sendAuth(challenge) {
const authTemplate = nip42.makeAuthEvent(RELAY_URL, challenge);
const signedAuth = finalizeEvent(authTemplate, secretKey);
authAttempts += 1;
log(`Sending AUTH attempt #${authAttempts} with challenge ${challenge}`);
sendJson(['AUTH', signedAuth]);
}
function scheduleNext() {
if (nextSeq > EVENT_COUNT) {
log(`All ${EVENT_COUNT} kind-4 events were accepted without disconnect`);
cleanupAndExit(0);
return;
}
setTimeout(() => {
if (!finished) {
publishNext();
}
}, DELAY_MS);
}
function publishNext() {
if (waitingForAuth) {
log('Still waiting for auth completion, postponing publish');
return;
}
const ev = makeKind4Event(nextSeq);
pendingEvent = ev;
log(`Publishing kind-4 seq=${nextSeq} id=${ev.id.slice(0, 12)}...`);
sendJson(['EVENT', ev]);
}
function retryPendingAfterAuth() {
if (!pendingEvent) return;
waitingForAuth = false;
log(`Retrying pending kind-4 id=${pendingEvent.id.slice(0, 12)}... after auth`);
sendJson(['EVENT', pendingEvent]);
}
function handleAuthRequiredSignal(source, msg) {
waitingForAuth = true;
log(`Relay signaled auth-required via ${source}: ${msg}`);
if (currentChallenge) {
sendAuth(currentChallenge);
} else {
log('No AUTH challenge received yet; waiting for relay AUTH challenge message');
}
}
function cleanupAndExit(code) {
if (finished) return;
finished = true;
if (overallTimer) clearTimeout(overallTimer);
const summary = {
relay: RELAY_URL,
sentAccepted,
expected: EVENT_COUNT,
authAttempts,
authenticated,
senderPubkey,
recipientPubkey,
};
if (code === 0) {
log('PASS: Connection remained stable through repeated kind-4 publish cycle', summary);
} else {
log('FAIL: Relay disconnected or test did not complete successfully', summary);
}
try {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.close();
}
} catch (_) {}
process.exit(code);
}
function main() {
log('Starting kind-4 disconnect probe', {
relay: RELAY_URL,
eventCount: EVENT_COUNT,
delayMs: DELAY_MS,
senderPubkey,
recipientPubkey,
});
ws = new WebSocket(RELAY_URL);
overallTimer = setTimeout(() => {
log(`Overall timeout reached (${OVERALL_TIMEOUT_MS}ms)`);
cleanupAndExit(1);
}, OVERALL_TIMEOUT_MS);
ws.on('open', () => {
log('WebSocket connected');
publishNext();
});
ws.on('message', (raw) => {
const text = raw.toString();
let msg;
try {
msg = JSON.parse(text);
} catch {
log(`Non-JSON relay message: ${text}`);
return;
}
const t = msg?.[0];
if (t === 'AUTH') {
currentChallenge = msg?.[1] || null;
log(`Received AUTH challenge: ${currentChallenge}`);
if (currentChallenge) sendAuth(currentChallenge);
return;
}
if (t === 'NOTICE') {
const notice = String(msg?.[1] || '');
log(`NOTICE: ${notice}`);
if (notice.toLowerCase().includes('auth-required')) {
handleAuthRequiredSignal('NOTICE', notice);
return;
}
if (notice.toLowerCase().includes('authentication successful')) {
authenticated = true;
log('Relay confirmed NIP-42 authentication success');
retryPendingAfterAuth();
}
return;
}
if (t === 'OK') {
const eventId = msg?.[1];
const ok = !!msg?.[2];
const reason = String(msg?.[3] || '');
log(`OK for ${String(eventId).slice(0, 12)}... accepted=${ok} reason=${reason}`);
if (!ok && reason.toLowerCase().includes('auth-required')) {
handleAuthRequiredSignal('OK', reason);
return;
}
if (pendingEvent && eventId === pendingEvent.id) {
if (ok) {
sentAccepted += 1;
nextSeq += 1;
pendingEvent = null;
waitingForAuth = false;
scheduleNext();
} else {
log(`Pending event rejected: ${reason}`);
cleanupAndExit(1);
}
}
return;
}
log('Relay message', msg);
});
ws.on('close', (code, reasonBuf) => {
const reason = reasonBuf?.toString?.() || '';
log(`WebSocket closed code=${code} reason=${reason}`);
if (!finished) {
if (sentAccepted >= EVENT_COUNT) {
cleanupAndExit(0);
} else {
cleanupAndExit(1);
}
}
});
ws.on('error', (err) => {
log(`WebSocket error: ${err.message}`);
cleanupAndExit(1);
});
}
main();