super_ball/web/thrower.html

2127 lines
82 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Superball Thrower</title>
<link rel="stylesheet" href="superball-shared.css">
</head>
<body>
<div id="login-section">
<h1>Superball Thrower</h1>
<h3 style="max-width: 500px; margin: 0 auto;">Login as an existing or new Thrower, not with your personal key.</h3>
<div id="login-container"></div>
</div>
<div id="main-content" class="hidden">
<h1>Superball Thrower</h1>
<!-- Profile Section - matching superball.html presentation -->
<img id="profile-picture">
<div id="profile-name"></div>
<div id="profile-pubkey"></div>
<!-- Start Thrower Button (moved above main section) -->
<div style="margin: 20px 0 5px 0; width: 100%;">
<button id="daemon-toggle" class="button-primary" onclick="toggleDaemon()"
style="width: 100%; font-size: 16px; font-weight: bold; margin-bottom: 0;">
<span id="daemon-button-text">Start Thrower</span>
</button>
</div>
<!-- Thrower Identity & Information Document Section (SUP-06) -->
<div class="section">
<div id="thrower-info-display">
<img id="thrower-banner" alt="Thrower Banner"
style="display: none; width: 100%; max-height: 200px; object-fit: cover; border-radius: 5px; margin-bottom: 10px;">
<!-- Integrated Thrower Control and Status -->
<div class="daemon-control" style="margin: 20px 0;">
<div id="daemon-status">
<div><strong>Status:</strong> <span id="daemon-status-text">Stopped</span></div>
<div><strong>Monitoring Relays:</strong> <span id="monitoring-relays">0</span></div>
<div><strong>Events Processed:</strong> <span id="events-processed">0</span></div>
<div><strong>Events in Queue:</strong> <span id="events-queued">0</span></div>
<div><strong>Info Status:</strong> <span id="thrower-info-status">Loading...</span></div>
<div><strong>Last Updated:</strong> <span id="thrower-info-updated">Never</span></div>
<div><strong>Refresh Rate:</strong> <span id="thrower-info-refresh">60 seconds</span></div>
</div>
</div>
<!-- Relay Configuration (moved here from separate section) -->
<div style="margin-top: 20px;">
<!-- Display View (Default) -->
<div id="relay-display-view">
<div id="relay-list">
<!-- Relay items will be populated here -->
</div>
</div>
<!-- Edit View (Hidden by default) -->
<div id="relay-edit-view" class="hidden">
<p style="margin: 10px 0; font-size: 14px;">Configure which relays this Superball node will monitor for
routing events. Follow NIP-65 standards.</p>
<div class="input-group">
<label>Add New Relay:</label>
<div class="add-relay-form">
<input type="url" id="new-relay-url" placeholder="wss://relay.example.com">
<select id="new-relay-type">
<option value="">Both</option>
<option value="read">Read</option>
<option value="write">Write</option>
</select>
<button class="button-primary" onclick="addRelay()">Add</button>
</div>
</div>
<div id="relay-edit-list">
<!-- Relay edit items will be populated here -->
</div>
<div class="action-buttons">
<button class="button-primary" onclick="saveRelayList()">Save Relay Configuration</button>
<button onclick="loadRelayList()">Reload from Network</button>
<button onclick="cancelRelayEdit()">Cancel</button>
</div>
</div>
<div id="relay-status"></div>
</div>
<div class="action-buttons" style="margin-top: 15px;">
<button onclick="toggleEditThrowerInfo()">Edit Thrower Info</button>
<button onclick="toggleRelayEdit()">Edit Relays</button>
<button onclick="testAllRelays()">Test All Relays</button>
</div>
</div>
<div id="thrower-info-edit" class="hidden">
<div class="input-group">
<label for="edit-thrower-name">Thrower Name:</label>
<input type="text" id="edit-thrower-name" placeholder="My Superball Thrower">
</div>
<div class="input-group">
<label for="edit-thrower-description">Description:</label>
<textarea id="edit-thrower-description" rows="3"
placeholder="Description of this Thrower's services and capabilities"></textarea>
</div>
<div class="input-group">
<label for="edit-thrower-banner">Banner URL (optional):</label>
<input type="url" id="edit-thrower-banner" placeholder="https://example.com/banner.jpg">
</div>
<div class="input-group">
<label for="edit-thrower-icon">Icon URL (optional):</label>
<input type="url" id="edit-thrower-icon" placeholder="https://example.com/icon.png">
</div>
<div class="input-group">
<label for="edit-admin-pubkey">Admin Contact Pubkey (optional):</label>
<input type="text" id="edit-admin-pubkey" placeholder="npub... or hex pubkey">
</div>
<div class="input-group">
<label for="edit-admin-contact">Alternate Contact (optional):</label>
<input type="text" id="edit-admin-contact" placeholder="email@example.com or other contact">
</div>
<div class="input-group">
<label for="edit-supported-sups">Supported SUPs:</label>
<input type="text" id="edit-supported-sups" placeholder="1,2,3,4,5,6" value="1,2,3,4,5,6">
</div>
<div class="input-group">
<label for="edit-software-url">Software URL:</label>
<input type="url" id="edit-software-url" placeholder="https://git.laantungir.net/laantungir/super_ball.git"
value="https://git.laantungir.net/laantungir/super_ball.git">
</div>
<div class="input-group">
<label for="edit-version">Version:</label>
<input type="text" id="edit-version" placeholder="1.0.0" value="1.0.0">
</div>
<div class="input-group">
<label for="edit-privacy-policy">Privacy Policy URL (optional):</label>
<input type="url" id="edit-privacy-policy" placeholder="https://example.com/privacy.txt">
</div>
<div class="input-group">
<label for="edit-terms-service">Terms of Service URL (optional):</label>
<input type="url" id="edit-terms-service" placeholder="https://example.com/terms.txt">
</div>
<div class="input-group">
<label for="edit-refresh-rate">Refresh Rate (seconds):</label>
<input type="number" id="edit-refresh-rate" placeholder="60" value="60" min="10" max="3600">
</div>
<div class="input-group">
<label for="edit-thrower-content">Additional Content (optional):</label>
<textarea id="edit-thrower-content" rows="2"
placeholder="Any additional information about this Thrower"></textarea>
</div>
<div class="action-buttons">
<button class="button-primary" onclick="saveThrowerInfo()">Save Thrower Info</button>
<button onclick="cancelEditThrowerInfo()">Cancel</button>
</div>
</div>
<div id="thrower-info-status-display"></div>
</div>
<!-- Event Queue Section -->
<div class="section">
<h2>Event Queue</h2>
<div id="event-queue">
<div class="info status-message">No events in queue</div>
</div>
</div>
<!-- Processing Log Section -->
<div class="section">
<h2>Processing Log</h2>
<div id="processing-log">
<div class="info status-message">Thrower stopped - no activity</div>
</div>
</div>
</div>
<!-- Load the official nostr-tools bundle first -->
<!-- <script src="./nostr.bundle.js"></script> -->
<script src="https://laantungir.net/nostr-login-lite/nostr.bundle.js"></script>
<!-- Load NOSTR_LOGIN_LITE main library -->
<script src="https://laantungir.net/nostr-login-lite/nostr-lite.js"></script>
<!-- <script src="./nostr-lite.js"></script> -->
<script>
// Global variables
let nlLite = null;
let userPubkey = null;
let relayUrl = 'wss://relay.laantungir.net';
let currentProfile = {};
let currentRelays = [];
let currentThrowerInfo = {};
let relayAuthTests = new Map(); // Store auth test results
// Daemon variables
let daemonRunning = false;
let monitoringWebSockets = [];
let eventQueue = [];
let processedEvents = 0;
let logEntries = [];
let subscriptionId = null;
let processedEventIds = new Set(); // Track processed event IDs for deduplication
// SUP-06 variables
let throwerInfoRefreshInterval = null;
let lastThrowerInfoPublish = null;
// Initialize NOSTR_LOGIN_LITE
async function initializeApp() {
try {
await window.NOSTR_LOGIN_LITE.init({
persistence: true, // Enable persistent authentication (default: true)
isolateSession: true, // Use sessionStorage for per-tab isolation (default: false = localStorage)
theme: 'default',
darkMode: false,
methods: {
extension: true,
local: true,
seedphrase: true,
readonly: false,
connect: true,
remote: true,
otp: false
},
debug: true
});
nlLite = window.NOSTR_LOGIN_LITE;
console.log('SUCCESS', 'NOSTR_LOGIN_LITE initialized successfully');
// Embed login interface
window.NOSTR_LOGIN_LITE.embed('#login-container', { seamless: true });
window.addEventListener('nlMethodSelected', handleAuthEvent);
} catch (error) {
console.log('ERROR', `Initialization failed: ${error.message}`);
}
}
function handleAuthEvent(event) {
const { pubkey, method, error } = event.detail;
console.log('INFO', `Auth event received: method=${method}`);
if (method && pubkey) {
userPubkey = pubkey;
console.log('SUCCESS', `Login successful! Method: ${method}, Pubkey: ${pubkey}`);
// Hide login section and show main content
document.getElementById('login-section').classList.add('hidden');
document.getElementById('main-content').classList.remove('hidden');
document.getElementById('profile-pubkey').textContent = pubkey;
loadUserProfile();
loadRelayList();
loadThrowerInfo();
// Update profile display with data from nostr-lite.js
setTimeout(updateProfileDisplay, 1000);
// Test relay authentication automatically after loading
setTimeout(testAllRelays, 2000);
} else if (error) {
console.log('ERROR', `Authentication error: ${error}`);
}
}
// Load user profile from kind 12222 (Thrower Information Document)
async function loadUserProfile() {
if (!userPubkey) return;
console.log('INFO', `Loading Thrower Information Document for profile display: ${userPubkey}`);
document.getElementById('profile-name').textContent = 'Loading...';
document.getElementById('profile-pubkey').textContent = userPubkey;
try {
const pool = new window.NostrTools.SimplePool();
const relays = [relayUrl, 'wss://relay.laantungir.net'];
const events = await pool.querySync(relays, {
kinds: [12222],
authors: [userPubkey],
limit: 1
});
pool.close(relays);
if (events.length > 0) {
console.log('SUCCESS', 'Thrower Information Document received for profile');
const throwerInfo = events[0];
displayProfileFromThrowerInfo(throwerInfo);
} else {
console.log('INFO', 'No Thrower Information Document found for profile');
document.getElementById('profile-name').textContent = 'Unnamed Thrower';
}
} catch (error) {
console.log('ERROR', `Profile loading from kind 12222 failed: ${error.message}`);
document.getElementById('profile-name').textContent = 'Error loading thrower info';
}
}
function displayProfileFromThrowerInfo(throwerInfo) {
// Extract information from kind 12222 tags
let name = '';
let description = '';
let icon = '';
throwerInfo.tags.forEach(tag => {
switch (tag[0]) {
case 'name':
name = tag[1] || '';
break;
case 'description':
description = tag[1] || '';
break;
case 'icon':
icon = tag[1] || '';
break;
}
});
// Update profile display elements
document.getElementById('profile-name').textContent = name || 'Unnamed Thrower';
if (icon) {
document.getElementById('profile-picture').src = icon;
document.getElementById('profile-picture').style.display = 'block';
}
console.log('SUCCESS', `Profile displayed from kind 12222: ${name || 'Unnamed Thrower'}`);
}
async function updateProfileDisplay() {
if (!userPubkey) return;
console.log('INFO', `Loading kind 12222 Thrower Information Document for: ${userPubkey}`);
try {
const pool = new window.NostrTools.SimplePool();
const relays = [relayUrl, 'wss://relay.laantungir.net'];
const events = await pool.querySync(relays, {
kinds: [12222],
authors: [userPubkey],
limit: 1
});
pool.close(relays);
if (events.length > 0) {
const throwerInfo = events[0];
console.log('SUCCESS', 'Kind 12222 Thrower Information Document received');
// Extract information from tags
let name = '';
let description = '';
let banner = '';
let icon = '';
let software = '';
throwerInfo.tags.forEach(tag => {
switch (tag[0]) {
case 'name':
name = tag[1] || '';
break;
case 'description':
description = tag[1] || '';
break;
case 'banner':
banner = tag[1] || '';
break;
case 'icon':
icon = tag[1] || '';
break;
case 'software':
software = tag[1] || '';
break;
}
});
// Update display elements (using correct IDs from HTML)
const nameElement = document.getElementById('thrower-display-name');
const descElement = document.getElementById('thrower-display-description');
if (nameElement) nameElement.textContent = name || 'Unnamed Thrower';
if (descElement) descElement.textContent = description || 'No description available';
// Update icon
const iconImg = document.getElementById('thrower-icon');
if (icon && iconImg) {
iconImg.src = icon;
iconImg.style.display = 'block';
}
// Update banner
const bannerImg = document.getElementById('thrower-banner');
if (banner && bannerImg) {
bannerImg.src = banner;
bannerImg.style.display = 'block';
}
console.log('SUCCESS', 'Profile display updated with kind 12222 data');
} else {
console.log('INFO', 'No kind 12222 Thrower Information Document found, using defaults');
const nameElement = document.getElementById('thrower-display-name');
const descElement = document.getElementById('thrower-display-description');
if (nameElement) nameElement.textContent = 'Unnamed Thrower';
if (descElement) descElement.textContent = 'No description available';
}
} catch (error) {
console.log('ERROR', `Failed to load kind 12222: ${error.message}`);
const nameElement = document.getElementById('thrower-display-name');
const descElement = document.getElementById('thrower-display-description');
if (nameElement) nameElement.textContent = 'Error loading info';
if (descElement) descElement.textContent = 'Failed to load thrower information';
}
}
// Load relay list (NIP-65)
async function loadRelayList() {
if (!userPubkey) return;
console.log('INFO', `Loading relay list for: ${userPubkey}`);
try {
const pool = new window.NostrTools.SimplePool();
const relays = [relayUrl, 'wss://relay.laantungir.net'];
const events = await pool.querySync(relays, {
kinds: [10002],
authors: [userPubkey],
limit: 1
});
pool.close(relays);
currentRelays = [];
if (events.length > 0) {
console.log('SUCCESS', 'Relay list event received');
const relayTags = events[0].tags.filter(tag => tag[0] === 'r');
currentRelays = relayTags.map(tag => ({
url: tag[1],
type: tag[2] || '',
authStatus: 'unknown', // unknown, no-auth, auth-required, error
lastTested: null
}));
} else {
console.log('INFO', 'No relay list found, using defaults');
currentRelays = [
{ url: 'wss://relay.laantungir.net', type: '', authStatus: 'unknown', lastTested: null }
];
}
displayRelayList();
} catch (error) {
console.log('ERROR', `Relay list loading failed: ${error.message}`);
showStatus('relay-status', 'Error loading relay list: ' + error.message, 'error');
}
}
// Display relay list
function displayRelayList() {
// Update display view (read-only)
const displayContainer = document.getElementById('relay-list');
displayContainer.innerHTML = '';
if (currentRelays.length === 0) {
displayContainer.innerHTML = '<div class="info status-message">No relays configured. Click "Edit Relays" to add some.</div>';
} else {
currentRelays.forEach((relay, index) => {
const relayItem = document.createElement('div');
relayItem.className = 'relay-item';
// Get authentication status
const authStatus = relay.authStatus || 'unknown';
let authIndicatorClass = 'testing';
let authStatusText = 'Unknown';
switch (authStatus) {
case 'no-auth':
authIndicatorClass = 'read-write';
authStatusText = 'Read/Write';
break;
case 'auth-required':
authIndicatorClass = 'read-only';
authStatusText = 'Read Only';
break;
case 'error':
authIndicatorClass = 'error';
authStatusText = 'Error';
break;
case 'testing':
authIndicatorClass = 'testing';
authStatusText = 'Testing...';
break;
default:
authIndicatorClass = 'testing';
authStatusText = 'Unknown';
}
relayItem.innerHTML = `
<div class="relay-url">${relay.url}</div>
<div class="relay-auth-status">
<div class="auth-indicator ${authIndicatorClass}"></div>
<div class="auth-status-text">${authStatusText}</div>
</div>
`;
displayContainer.appendChild(relayItem);
});
}
// Update edit view (with full controls)
const editContainer = document.getElementById('relay-edit-list');
if (editContainer) {
editContainer.innerHTML = '';
if (currentRelays.length === 0) {
editContainer.innerHTML = '<div class="info status-message">No relays configured. Add relays above.</div>';
} else {
currentRelays.forEach((relay, index) => {
const relayItem = document.createElement('div');
relayItem.className = 'relay-item';
// Get authentication status
const authStatus = relay.authStatus || 'unknown';
let authIndicatorClass = 'testing';
let authStatusText = 'Unknown';
switch (authStatus) {
case 'no-auth':
authIndicatorClass = 'read-write';
authStatusText = 'Read/Write';
break;
case 'auth-required':
authIndicatorClass = 'read-only';
authStatusText = 'Read Only (Auth/PoW)';
break;
case 'error':
authIndicatorClass = 'error';
authStatusText = 'Error';
break;
case 'testing':
authIndicatorClass = 'testing';
authStatusText = 'Testing...';
break;
default:
authIndicatorClass = 'testing';
authStatusText = 'Unknown';
}
relayItem.innerHTML = `
<div class="relay-url">${relay.url}</div>
<div class="relay-auth-status">
<div class="auth-indicator ${authIndicatorClass}"></div>
<div class="auth-status-text">${authStatusText}</div>
</div>
<div class="relay-actions">
<button onclick="testSingleRelay(${index})">Test</button>
<button onclick="removeRelay(${index})">Remove</button>
</div>
`;
editContainer.appendChild(relayItem);
});
}
}
}
// Toggle relay edit mode
function toggleRelayEdit() {
const displayView = document.getElementById('relay-display-view');
const editView = document.getElementById('relay-edit-view');
displayView.classList.add('hidden');
editView.classList.remove('hidden');
// Update the edit list
displayRelayList();
}
// Cancel relay edit mode
function cancelRelayEdit() {
const displayView = document.getElementById('relay-display-view');
const editView = document.getElementById('relay-edit-view');
editView.classList.add('hidden');
displayView.classList.remove('hidden');
// Clear the add relay form
document.getElementById('new-relay-url').value = '';
document.getElementById('new-relay-type').value = '';
// Just refresh the display without reloading from network to preserve auth status
displayRelayList();
}
// Add new relay
function addRelay() {
const url = document.getElementById('new-relay-url').value.trim();
const type = document.getElementById('new-relay-type').value;
if (!url) {
showStatus('relay-status', 'Please enter a relay URL', 'error');
return;
}
if (!url.startsWith('wss://') && !url.startsWith('ws://')) {
showStatus('relay-status', 'Relay URL must start with wss:// or ws://', 'error');
return;
}
// Check for duplicates
if (currentRelays.some(r => r.url === url)) {
showStatus('relay-status', 'Relay already exists', 'error');
return;
}
currentRelays.push({ url, type });
displayRelayList();
// Clear form
document.getElementById('new-relay-url').value = '';
document.getElementById('new-relay-type').value = '';
showStatus('relay-status', 'Relay added (remember to save)', 'info');
}
// Remove relay
function removeRelay(index) {
currentRelays.splice(index, 1);
displayRelayList();
showStatus('relay-status', 'Relay removed (remember to save)', 'info');
}
// Save relay list (NIP-65)
async function saveRelayList() {
if (!userPubkey) return;
try {
const tags = currentRelays.map(relay => {
const tag = ['r', relay.url];
if (relay.type) {
tag.push(relay.type);
}
return tag;
});
const eventTemplate = {
kind: 10002,
content: '',
tags: tags,
created_at: Math.floor(Date.now() / 1000)
};
console.log('DEBUG: Relay event template to sign:', JSON.stringify(eventTemplate, null, 2));
const signedEvent = await window.nostr.signEvent(eventTemplate);
console.log('DEBUG: Signed relay event:', JSON.stringify(signedEvent, null, 2));
// Publish to relays with detailed per-relay logging
const relaysToUse = [...new Set([relayUrl, 'wss://relay.laantungir.net'])]; // Remove duplicates
console.log('DEBUG: Publishing relay list to relays:', relaysToUse);
const pool = new window.NostrTools.SimplePool();
try {
// Publish to each relay individually and collect results
const publishPromises = relaysToUse.map(relayUrl => {
const promises = pool.publish([relayUrl], signedEvent); // Returns array of promises
return promises[0] // Get the first (and only) promise for this relay
.then(result => ({ relayUrl, success: true, result }))
.catch(error => ({ relayUrl, success: false, error: error.message }));
});
const results = await Promise.allSettled(publishPromises);
let successCount = 0;
let failureCount = 0;
results.forEach((promiseResult) => {
if (promiseResult.status === 'fulfilled') {
const { relayUrl, success, error } = promiseResult.value;
if (success) {
successCount++;
console.log('SUCCESS', `✅ Relay list published successfully to ${relayUrl}`);
} else {
failureCount++;
console.log('ERROR', `❌ Failed to publish relay list to ${relayUrl}: ${error}`);
}
}
});
pool.close(relaysToUse);
if (successCount > 0) {
showStatus('relay-status', 'Relay configuration saved successfully!', 'success');
console.log('SUCCESS', `Relay list published to ${successCount} out of ${relaysToUse.length} relays`);
// Return to display mode after successful save
cancelRelayEdit();
} else {
throw new Error(`Failed to publish relay list to any relay (attempted: ${relaysToUse.join(', ')})`);
}
} catch (error) {
pool.close(relaysToUse);
throw error;
}
} catch (error) {
console.log('ERROR', `Failed to save relay list: ${error.message}`);
showStatus('relay-status', 'Failed to save relay list: ' + error.message, 'error');
}
}
// Show status message
function showStatus(containerId, message, type) {
const container = document.getElementById(containerId);
container.innerHTML = `<div class="status-message ${type}">${message}</div>`;
// Auto-clear after 5 seconds for non-error messages
if (type !== 'error') {
setTimeout(() => {
container.innerHTML = '';
}, 5000);
}
}
// ============ RELAY AUTHENTICATION TESTING FUNCTIONALITY ============
// Test authentication requirements for a single relay
async function testSingleRelay(relayIndex) {
if (relayIndex >= currentRelays.length) return;
const relay = currentRelays[relayIndex];
console.log('INFO', `Testing authentication for relay: ${relay.url}`);
// Update status to testing
relay.authStatus = 'testing';
displayRelayList();
try {
const authResult = await testRelayAuthentication(relay.url);
relay.authStatus = authResult;
relay.lastTested = Date.now();
console.log('SUCCESS', `Auth test completed for ${relay.url}: ${authResult}`);
displayRelayList();
return authResult;
} catch (error) {
console.log('ERROR', `Auth test failed for ${relay.url}: ${error.message}`);
relay.authStatus = 'error';
relay.lastTested = Date.now();
displayRelayList();
return 'error';
}
}
// Test authentication requirements for all relays
async function testAllRelays() {
if (currentRelays.length === 0) {
return;
}
console.log('INFO', `Testing authentication for ${currentRelays.length} relays...`);
// Set all to testing status
currentRelays.forEach(relay => {
relay.authStatus = 'testing';
});
displayRelayList();
// Test all relays in parallel
const testPromises = currentRelays.map((relay, index) =>
testSingleRelay(index).catch(error => {
console.log('ERROR', `Failed to test relay ${relay.url}: ${error.message}`);
return 'error';
})
);
try {
await Promise.allSettled(testPromises);
const results = currentRelays.map(r => r.authStatus);
const readWriteCount = results.filter(r => r === 'no-auth').length;
const readOnlyCount = results.filter(r => r === 'auth-required').length;
const errorCount = results.filter(r => r === 'error').length;
console.log('SUCCESS', `Relay auth testing complete: ${readWriteCount} read/write, ${readOnlyCount} read-only, ${errorCount} errors`);
// Auto-update relay list with new auth status
await updateRelayListFromAuthResults();
} catch (error) {
console.log('ERROR', `Relay auth testing failed: ${error.message}`);
}
}
// Test relay authentication by attempting anonymous post
async function testRelayAuthentication(relayUrl) {
console.log('INFO', `Testing auth for relay: ${relayUrl}`);
addLogEntry('info', `Testing authentication for relay: ${relayUrl}`);
return new Promise((resolve) => {
// Set timeout for test
const timeout = setTimeout(() => {
addLogEntry('error', `Auth test timed out for ${relayUrl} (10s timeout)`);
ws.close();
resolve('error');
}, 10000); // 10 second timeout
const ws = new WebSocket(relayUrl);
let authRequired = false;
let publishAttempted = false;
let testEventId = null;
ws.onopen = () => {
console.log('INFO', `Connected to ${relayUrl} for auth test`);
addLogEntry('success', `Connected to ${relayUrl} for auth test`);
// Generate test event with random key
const testKey = window.NostrTools.generateSecretKey();
const testPubkey = window.NostrTools.getPublicKey(testKey);
const testEvent = {
kind: 1,
content: `Auth test ${Date.now()}`,
tags: [],
created_at: Math.floor(Date.now() / 1000)
};
const signedEvent = window.NostrTools.finalizeEvent(testEvent, testKey);
testEventId = signedEvent.id;
// Attempt to publish
const publishMsg = JSON.stringify(['EVENT', signedEvent]);
ws.send(publishMsg);
publishAttempted = true;
console.log('INFO', `Sent test event to ${relayUrl}`);
addLogEntry('info', `Sent test event ${testEventId.substring(0, 16)}... to ${relayUrl}`);
};
ws.onmessage = (event) => {
try {
const message = JSON.parse(event.data);
console.log('INFO', `Received from ${relayUrl}:`, message);
// Log ALL responses from relay in processing log
addLogEntry('info', `📥 Response from ${relayUrl}: ${JSON.stringify(message)}`);
if (message[0] === 'AUTH') {
// Relay requires authentication
authRequired = true;
addLogEntry('info', `🔐 ${relayUrl} requires AUTH - marking as read-only`);
clearTimeout(timeout);
ws.close();
resolve('auth-required');
} else if (message[0] === 'OK') {
// Check if this is response to our publish attempt
if (publishAttempted && !authRequired) {
const eventId = message[1];
const accepted = message[2];
const reason = message[3] || '';
addLogEntry('info', `📝 OK response from ${relayUrl}: eventId=${eventId.substring(0, 16)}..., accepted=${accepted}, reason="${reason}"`);
if (accepted) {
// Event accepted - no restrictions, read/write capable
addLogEntry('success', `${relayUrl} accepted test event - read/write capable`);
clearTimeout(timeout);
ws.close();
resolve('no-auth');
} else {
// Event rejected for ANY reason - treat as read-only
addLogEntry('info', `🚫 ${relayUrl} rejected test event: "${reason}" - marking as read-only`);
clearTimeout(timeout);
ws.close();
resolve('auth-required');
}
}
} else if (message[0] === 'NOTICE') {
const notice = message[1] || '';
addLogEntry('info', `📢 Notice from ${relayUrl}: "${notice}"`);
if (notice.toLowerCase().includes('auth')) {
authRequired = true;
addLogEntry('info', `🔐 ${relayUrl} notice indicates auth required`);
clearTimeout(timeout);
ws.close();
resolve('auth-required');
}
}
} catch (error) {
console.log('ERROR', `Error parsing message from ${relayUrl}: ${error.message}`);
addLogEntry('error', `❌ Error parsing message from ${relayUrl}: ${error.message}`);
}
};
ws.onerror = (error) => {
console.log('ERROR', `WebSocket error with ${relayUrl}:`, error);
addLogEntry('error', `❌ WebSocket error with ${relayUrl}: ${error}`);
clearTimeout(timeout);
resolve('error');
};
ws.onclose = (event) => {
addLogEntry('info', `🔌 Connection closed to ${relayUrl} - Code: ${event.code}, Reason: ${event.reason || 'No reason given'}`);
clearTimeout(timeout);
// If we haven't resolved yet and didn't see auth requirement, assume no auth needed
if (!authRequired && publishAttempted) {
addLogEntry('info', `${relayUrl} closed without clear response - assuming no auth required`);
resolve('no-auth');
}
};
});
}
// Update NIP-65 relay list based on authentication test results
async function updateRelayListFromAuthResults() {
if (!userPubkey) return;
console.log('INFO', 'Updating relay list with auth test results...');
try {
// Update relay types based on auth status
currentRelays.forEach(relay => {
if (relay.authStatus === 'auth-required') {
// AUTH required - mark as read only
relay.type = 'read';
} else if (relay.authStatus === 'no-auth') {
// No AUTH required - mark as read/write (both)
relay.type = ''; // Empty means both read and write
}
// Keep existing type for error or unknown status
});
// Save updated relay list
await saveRelayListSilently();
console.log('SUCCESS', 'Relay list updated with auth test results');
} catch (error) {
console.log('ERROR', `Failed to update relay list: ${error.message}`);
}
}
// Save relay list without showing status message (for auto-updates)
async function saveRelayListSilently() {
if (!userPubkey) return;
try {
const tags = currentRelays.map(relay => {
const tag = ['r', relay.url];
if (relay.type) {
tag.push(relay.type);
}
return tag;
});
const eventTemplate = {
kind: 10002,
content: '',
tags: tags,
created_at: Math.floor(Date.now() / 1000)
};
const signedEvent = await window.nostr.signEvent(eventTemplate);
const relaysToUse = [...new Set([relayUrl, 'wss://relay.laantungir.net'])];
const pool = new window.NostrTools.SimplePool();
// Publish to each relay individually for silent update
const publishPromises = relaysToUse.map(relayUrl => {
const promises = pool.publish([relayUrl], signedEvent); // Returns array of promises
return promises[0] // Get the first (and only) promise for this relay
.then(() => ({ relayUrl, success: true }))
.catch(error => ({ relayUrl, success: false, error: error.message }));
});
const results = await Promise.allSettled(publishPromises);
let successCount = 0;
results.forEach((promiseResult) => {
if (promiseResult.status === 'fulfilled' && promiseResult.value.success) {
successCount++;
console.log('SUCCESS', `✅ Relay list silently updated to ${promiseResult.value.relayUrl}`);
} else if (promiseResult.status === 'fulfilled') {
console.log('ERROR', `❌ Failed silent update to ${promiseResult.value.relayUrl}: ${promiseResult.value.error}`);
}
});
pool.close(relaysToUse);
if (successCount > 0) {
console.log('SUCCESS', `Relay list updated silently to ${successCount} out of ${relaysToUse.length} relays`);
} else {
throw new Error(`Failed to update relay list to any relay (attempted: ${relaysToUse.join(', ')})`);
}
} catch (error) {
console.log('ERROR', `Failed to save relay list silently: ${error.message}`);
throw error;
}
}
// ============ SUP-06 THROWER INFORMATION DOCUMENT FUNCTIONALITY ============
// Load Thrower Information Document (kind 12222)
async function loadThrowerInfo() {
if (!userPubkey) return;
console.log('INFO', `Loading Thrower Information Document for: ${userPubkey}`);
document.getElementById('thrower-info-status').textContent = 'Loading...';
try {
const pool = new window.NostrTools.SimplePool();
const relays = [relayUrl, 'wss://relay.laantungir.net'];
const events = await pool.querySync(relays, {
kinds: [12222],
authors: [userPubkey],
limit: 1
});
pool.close(relays);
if (events.length > 0) {
console.log('SUCCESS', 'Thrower Info Document event received');
const event = events[0];
// Parse tags into thrower info object
currentThrowerInfo = {
name: '',
description: '',
banner: '',
icon: '',
adminPubkey: '',
contact: '',
supportedSups: '1,2,3,4,5,6',
software: 'https://github.com/superball/thrower',
version: '1.0.0',
privacyPolicy: '',
termsOfService: '',
refreshRate: 60,
content: event.content || ''
};
// Parse tags
event.tags.forEach(tag => {
if (tag[0] === 'name') currentThrowerInfo.name = tag[1] || '';
else if (tag[0] === 'description') currentThrowerInfo.description = tag[1] || '';
else if (tag[0] === 'banner') currentThrowerInfo.banner = tag[1] || '';
else if (tag[0] === 'icon') currentThrowerInfo.icon = tag[1] || '';
else if (tag[0] === 'pubkey') currentThrowerInfo.adminPubkey = tag[1] || '';
else if (tag[0] === 'contact') currentThrowerInfo.contact = tag[1] || '';
else if (tag[0] === 'supported_sups') currentThrowerInfo.supportedSups = tag[1] || '1,2,3,4,5,6';
else if (tag[0] === 'software') currentThrowerInfo.software = tag[1] || 'https://github.com/superball/thrower';
else if (tag[0] === 'version') currentThrowerInfo.version = tag[1] || '1.0.0';
else if (tag[0] === 'privacy_policy') currentThrowerInfo.privacyPolicy = tag[1] || '';
else if (tag[0] === 'terms_of_service') currentThrowerInfo.termsOfService = tag[1] || '';
else if (tag[0] === 'refresh_rate') currentThrowerInfo.refreshRate = parseInt(tag[1]) || 60;
});
lastThrowerInfoPublish = event.created_at;
displayThrowerInfo(currentThrowerInfo);
} else {
console.log('INFO', 'No Thrower Info Document found, using defaults');
currentThrowerInfo = {
name: 'My Superball Thrower',
description: 'A privacy-focused Superball Thrower node',
banner: '',
icon: '',
adminPubkey: '',
contact: '',
supportedSups: '1,2,3,4,5,6',
software: 'https://github.com/superball/thrower',
version: '1.0.0',
privacyPolicy: '',
termsOfService: '',
refreshRate: 60,
content: ''
};
displayThrowerInfo(currentThrowerInfo);
}
} catch (error) {
console.log('ERROR', `Thrower Info loading failed: ${error.message}`);
showStatus('thrower-info-status-display', 'Error loading Thrower Info: ' + error.message, 'error');
}
}
// Display thrower info data
function displayThrowerInfo(info) {
document.getElementById('thrower-info-status').textContent = 'Loaded';
document.getElementById('thrower-info-refresh').textContent = `${info.refreshRate} seconds`;
if (lastThrowerInfoPublish) {
const lastUpdate = new Date(lastThrowerInfoPublish * 1000);
document.getElementById('thrower-info-updated').textContent = lastUpdate.toLocaleString();
}
console.log('SUCCESS', `Thrower Info displayed: ${info.name}`);
}
// Toggle thrower info editing
function toggleEditThrowerInfo() {
const display = document.getElementById('thrower-info-display');
const edit = document.getElementById('thrower-info-edit');
display.classList.add('hidden');
edit.classList.remove('hidden');
// Populate edit fields
document.getElementById('edit-thrower-name').value = currentThrowerInfo.name || '';
document.getElementById('edit-thrower-description').value = currentThrowerInfo.description || '';
document.getElementById('edit-thrower-banner').value = currentThrowerInfo.banner || '';
document.getElementById('edit-thrower-icon').value = currentThrowerInfo.icon || '';
document.getElementById('edit-admin-pubkey').value = currentThrowerInfo.adminPubkey || '';
document.getElementById('edit-admin-contact').value = currentThrowerInfo.contact || '';
document.getElementById('edit-supported-sups').value = currentThrowerInfo.supportedSups || '1,2,3,4,5,6';
document.getElementById('edit-software-url').value = currentThrowerInfo.software || 'https://github.com/superball/thrower';
document.getElementById('edit-version').value = currentThrowerInfo.version || '1.0.0';
document.getElementById('edit-privacy-policy').value = currentThrowerInfo.privacyPolicy || '';
document.getElementById('edit-terms-service').value = currentThrowerInfo.termsOfService || '';
document.getElementById('edit-refresh-rate').value = currentThrowerInfo.refreshRate || 60;
document.getElementById('edit-thrower-content').value = currentThrowerInfo.content || '';
}
function cancelEditThrowerInfo() {
document.getElementById('thrower-info-display').classList.remove('hidden');
document.getElementById('thrower-info-edit').classList.add('hidden');
}
// Save Thrower Information Document
async function saveThrowerInfo() {
if (!userPubkey) return;
const name = document.getElementById('edit-thrower-name').value.trim();
const description = document.getElementById('edit-thrower-description').value.trim();
const banner = document.getElementById('edit-thrower-banner').value.trim();
const icon = document.getElementById('edit-thrower-icon').value.trim();
const adminPubkey = document.getElementById('edit-admin-pubkey').value.trim();
const contact = document.getElementById('edit-admin-contact').value.trim();
const supportedSups = document.getElementById('edit-supported-sups').value.trim();
const software = document.getElementById('edit-software-url').value.trim();
const version = document.getElementById('edit-version').value.trim();
const privacyPolicy = document.getElementById('edit-privacy-policy').value.trim();
const termsOfService = document.getElementById('edit-terms-service').value.trim();
const refreshRate = parseInt(document.getElementById('edit-refresh-rate').value) || 60;
const content = document.getElementById('edit-thrower-content').value.trim();
try {
// Build tags array according to SUP-06
const tags = [];
if (name) tags.push(['name', name]);
if (description) tags.push(['description', description]);
if (banner) tags.push(['banner', banner]);
if (icon) tags.push(['icon', icon]);
if (adminPubkey) tags.push(['pubkey', adminPubkey]);
if (contact) tags.push(['contact', contact]);
if (supportedSups) tags.push(['supported_sups', supportedSups]);
if (software) tags.push(['software', software]);
if (version) tags.push(['version', version]);
if (privacyPolicy) tags.push(['privacy_policy', privacyPolicy]);
if (termsOfService) tags.push(['terms_of_service', termsOfService]);
tags.push(['refresh_rate', refreshRate.toString()]);
const eventTemplate = {
kind: 12222,
content: content,
tags: tags,
created_at: Math.floor(Date.now() / 1000)
};
console.log('DEBUG: Thrower Info event template to sign:', JSON.stringify(eventTemplate, null, 2));
const signedEvent = await window.nostr.signEvent(eventTemplate);
console.log('DEBUG: Signed Thrower Info event:', JSON.stringify(signedEvent, null, 2));
// Publish to relays
const relaysToUse = [...new Set([relayUrl, 'wss://relay.laantungir.net'])];
console.log('DEBUG: Publishing Thrower Info to relays:', relaysToUse);
const pool = new window.NostrTools.SimplePool();
try {
// Publish to each relay individually for thrower info
const publishPromises = relaysToUse.map(relayUrl => {
const promises = pool.publish([relayUrl], signedEvent); // Returns array of promises
return promises[0] // Get the first (and only) promise for this relay
.then(() => ({ relayUrl, success: true }))
.catch(error => ({ relayUrl, success: false, error: error.message }));
});
const results = await Promise.allSettled(publishPromises);
let successCount = 0;
let failureCount = 0;
results.forEach((promiseResult) => {
if (promiseResult.status === 'fulfilled') {
const { relayUrl, success, error } = promiseResult.value;
if (success) {
successCount++;
console.log('SUCCESS', `✅ Thrower info published successfully to ${relayUrl}`);
} else {
failureCount++;
console.log('ERROR', `❌ Failed to publish thrower info to ${relayUrl}: ${error}`);
}
}
});
pool.close(relaysToUse);
if (successCount === 0) {
throw new Error(`Failed to publish thrower info to any relay (attempted: ${relaysToUse.join(', ')})`);
}
console.log('SUCCESS', `Thrower info published to ${successCount} out of ${relaysToUse.length} relays`);
// Update current thrower info
currentThrowerInfo = {
name: name || 'My Superball Thrower',
description: description || 'A privacy-focused Superball Thrower node',
banner,
icon,
adminPubkey,
contact,
supportedSups: supportedSups || '1,2,3,4,5,6',
software: software || 'https://github.com/superball/thrower',
version: version || '1.0.0',
privacyPolicy,
termsOfService,
refreshRate,
content
};
lastThrowerInfoPublish = signedEvent.created_at;
displayThrowerInfo(currentThrowerInfo);
cancelEditThrowerInfo();
showStatus('thrower-info-status-display', 'Thrower Information Document saved successfully!', 'success');
console.log('SUCCESS', 'Thrower Info published to at least one relay');
} catch (aggregateError) {
pool.close(relaysToUse);
console.log('ERROR: All Thrower Info publish attempts failed:', aggregateError.errors);
const errorMessages = aggregateError.errors.map((err, index) =>
`${relaysToUse[index]}: ${err.message}`
).join(', ');
throw new Error('Failed to publish Thrower Info to any relay: ' + errorMessages);
}
} catch (error) {
console.log('ERROR', `Failed to save Thrower Info: ${error.message}`);
showStatus('thrower-info-status-display', 'Failed to save Thrower Info: ' + error.message, 'error');
}
}
// Auto-republish thrower info when daemon is running
function startThrowerInfoAutoPublish() {
if (!currentThrowerInfo || !currentThrowerInfo.refreshRate) return;
// Clear existing interval
if (throwerInfoRefreshInterval) {
clearInterval(throwerInfoRefreshInterval);
}
// Schedule republishing 10 seconds before refresh rate expires
const intervalMs = Math.max(10000, (currentThrowerInfo.refreshRate - 10) * 1000);
console.log('INFO', `Starting Thrower Info auto-publish every ${intervalMs / 1000} seconds`);
throwerInfoRefreshInterval = setInterval(async () => {
if (daemonRunning && currentThrowerInfo.name) {
try {
console.log('INFO', 'Auto-publishing Thrower Information Document...');
await republishThrowerInfo();
showStatus('thrower-info-status-display', 'Thrower Info auto-published', 'info');
} catch (error) {
console.log('ERROR', `Auto-publish failed: ${error.message}`);
}
}
}, intervalMs);
}
function stopThrowerInfoAutoPublish() {
if (throwerInfoRefreshInterval) {
clearInterval(throwerInfoRefreshInterval);
throwerInfoRefreshInterval = null;
console.log('INFO', 'Stopped Thrower Info auto-publish');
}
}
// Republish current thrower info
async function republishThrowerInfo() {
if (!userPubkey || !currentThrowerInfo.name) return;
try {
const tags = [];
if (currentThrowerInfo.name) tags.push(['name', currentThrowerInfo.name]);
if (currentThrowerInfo.description) tags.push(['description', currentThrowerInfo.description]);
if (currentThrowerInfo.banner) tags.push(['banner', currentThrowerInfo.banner]);
if (currentThrowerInfo.icon) tags.push(['icon', currentThrowerInfo.icon]);
if (currentThrowerInfo.adminPubkey) tags.push(['pubkey', currentThrowerInfo.adminPubkey]);
if (currentThrowerInfo.contact) tags.push(['contact', currentThrowerInfo.contact]);
if (currentThrowerInfo.supportedSups) tags.push(['supported_sups', currentThrowerInfo.supportedSups]);
if (currentThrowerInfo.software) tags.push(['software', currentThrowerInfo.software]);
if (currentThrowerInfo.version) tags.push(['version', currentThrowerInfo.version]);
if (currentThrowerInfo.privacyPolicy) tags.push(['privacy_policy', currentThrowerInfo.privacyPolicy]);
if (currentThrowerInfo.termsOfService) tags.push(['terms_of_service', currentThrowerInfo.termsOfService]);
tags.push(['refresh_rate', currentThrowerInfo.refreshRate.toString()]);
const eventTemplate = {
kind: 12222,
content: currentThrowerInfo.content || '',
tags: tags,
created_at: Math.floor(Date.now() / 1000)
};
const signedEvent = await window.nostr.signEvent(eventTemplate);
const relaysToUse = [...new Set([relayUrl, 'wss://relay.laantungir.net'])];
const pool = new window.NostrTools.SimplePool();
// Publish to each relay individually for auto-republish
const publishPromises = relaysToUse.map(relayUrl => {
const promises = pool.publish([relayUrl], signedEvent); // Returns array of promises
return promises[0] // Get the first (and only) promise for this relay
.then(() => ({ relayUrl, success: true }))
.catch(error => ({ relayUrl, success: false, error: error.message }));
});
const results = await Promise.allSettled(publishPromises);
let successCount = 0;
results.forEach((promiseResult) => {
if (promiseResult.status === 'fulfilled') {
const { relayUrl, success, error } = promiseResult.value;
if (success) {
successCount++;
console.log('SUCCESS', `✅ Thrower info auto-republished to ${relayUrl}`);
} else {
console.log('ERROR', `❌ Failed auto-republish to ${relayUrl}: ${error}`);
}
}
});
pool.close(relaysToUse);
if (successCount === 0) {
throw new Error(`Failed to auto-republish thrower info to any relay (attempted: ${relaysToUse.join(', ')})`);
}
console.log('SUCCESS', `Thrower info auto-republished to ${successCount} out of ${relaysToUse.length} relays`);
lastThrowerInfoPublish = signedEvent.created_at;
document.getElementById('thrower-info-updated').textContent = new Date().toLocaleString();
console.log('SUCCESS', 'Thrower Info auto-republished');
} catch (error) {
console.log('ERROR', `Failed to republish Thrower Info: ${error.message}`);
throw error;
}
}
// ============ SUPERBALL THROWER FUNCTIONALITY ============
// Toggle thrower on/off
function toggleDaemon() {
if (daemonRunning) {
stopDaemon();
} else {
startDaemon();
}
}
// Start the Superball thrower
async function startDaemon() {
if (!userPubkey || currentRelays.length === 0) {
addLogEntry('error', 'Cannot start thrower: Profile not loaded or no relays configured');
showStatus('relay-status', 'Please configure profile and relays before starting thrower', 'error');
return;
}
try {
addLogEntry('info', 'Starting Superball thrower...');
daemonRunning = true;
updateDaemonUI();
// Test relay authentication automatically on startup
addLogEntry('info', 'Testing relay authentication capabilities...');
try {
await testAllRelays();
addLogEntry('success', 'Relay authentication testing completed');
} catch (error) {
addLogEntry('error', `Relay auth testing failed: ${error.message}`);
// Continue with startup even if auth testing fails
}
// Get relay URLs for monitoring (read from all relays)
const monitoringRelays = currentRelays.map(r => r.url);
addLogEntry('info', `Connecting to ${monitoringRelays.length} relays via WebSocket for ALL kind 22222 events`);
// Generate unique subscription ID
subscriptionId = 'superball_' + Date.now();
// Subscribe to kind 22222 events with p tag matching this node's pubkey and created_at > now
const now = Math.floor(Date.now() / 1000);
const subscriptionFilter = {
kinds: [22222],
'#p': [userPubkey],
since: now
};
addLogEntry('info', `Subscription ID: ${subscriptionId}`);
addLogEntry('info', `Subscription filter: ${JSON.stringify(subscriptionFilter)}`);
// Connect to each relay with direct WebSocket
monitoringRelays.forEach((relayUrl, index) => {
connectToRelay(relayUrl, subscriptionFilter);
});
// Update monitoring count
document.getElementById('monitoring-relays').textContent = monitoringRelays.length;
// Immediately publish Thrower Info Document to announce availability
if (currentThrowerInfo && currentThrowerInfo.name) {
try {
addLogEntry('info', 'Publishing Thrower Information Document to announce availability...');
await republishThrowerInfo();
addLogEntry('success', 'Thrower availability announced successfully');
} catch (error) {
addLogEntry('error', `Failed to announce thrower availability: ${error.message}`);
// Don't fail startup if this fails, just log it
}
} else {
addLogEntry('info', 'No Thrower Info configured - skipping availability announcement');
}
// Start auto-publishing Thrower Info Document for periodic updates
startThrowerInfoAutoPublish();
} catch (error) {
addLogEntry('error', `Failed to start thrower: ${error.message}`);
daemonRunning = false;
updateDaemonUI();
}
}
// Stop the Superball thrower
function stopDaemon() {
addLogEntry('info', 'Stopping Superball thrower...');
daemonRunning = false;
// Close all WebSocket connections
monitoringWebSockets.forEach((ws, index) => {
if (ws.readyState === WebSocket.OPEN) {
// Send CLOSE message for subscription
const closeMsg = JSON.stringify(['CLOSE', subscriptionId]);
ws.send(closeMsg);
addLogEntry('info', `Sent CLOSE to relay ${index + 1}`);
// Close WebSocket
ws.close();
}
});
monitoringWebSockets = [];
subscriptionId = null;
// Clear event queue
eventQueue = [];
// Clear processed event IDs for fresh start
processedEventIds.clear();
// Stop auto-publishing Thrower Info Document
stopThrowerInfoAutoPublish();
updateDaemonUI();
updateEventQueue();
addLogEntry('success', 'Thrower stopped successfully');
}
// Connect to relay via direct WebSocket
function connectToRelay(relayUrl, subscriptionFilter) {
addLogEntry('info', `Connecting to relay: ${relayUrl}`);
const ws = new WebSocket(relayUrl);
ws.onopen = () => {
addLogEntry('success', `Connected to relay: ${relayUrl}`);
// Send subscription request
const reqMessage = JSON.stringify([
'REQ',
subscriptionId,
subscriptionFilter
]);
addLogEntry('info', `Sending REQ to ${relayUrl}: ${reqMessage}`);
ws.send(reqMessage);
};
ws.onmessage = (event) => {
try {
const message = JSON.parse(event.data);
addLogEntry('info', `Received from ${relayUrl}: ${JSON.stringify(message).substring(0, 200)}...`);
// Handle different message types
if (message[0] === 'EVENT' && message[1] === subscriptionId) {
const nostrEvent = message[2];
addLogEntry('success', `Received EVENT from ${relayUrl}: ${nostrEvent.id.substring(0, 16)}...`);
// Log full event without truncation
addLogEntry('info', `Full received event:\n${JSON.stringify(nostrEvent, null, 2)}`);
handleIncomingEvent(nostrEvent);
} else if (message[0] === 'EOSE' && message[1] === subscriptionId) {
addLogEntry('info', `End of stored events from ${relayUrl}`);
} else if (message[0] === 'NOTICE') {
addLogEntry('info', `Notice from ${relayUrl}: ${message[1]}`);
} else if (message[0] === 'OK') {
addLogEntry('info', `OK response from ${relayUrl}: ${JSON.stringify(message)}`);
}
} catch (error) {
addLogEntry('error', `Error parsing message from ${relayUrl}: ${error.message}`);
}
};
ws.onerror = (error) => {
addLogEntry('error', `WebSocket error with ${relayUrl}: ${error}`);
};
ws.onclose = (event) => {
addLogEntry('info', `Connection closed to ${relayUrl} - Code: ${event.code}, Reason: ${event.reason}`);
};
monitoringWebSockets.push(ws);
}
// Handle incoming routing events
async function handleIncomingEvent(event) {
// Deduplication check: Skip if we've already processed this event ID
if (processedEventIds.has(event.id)) {
addLogEntry('info', `Skipping duplicate event: ${event.id.substring(0, 16)}... (already processed)`);
return;
}
// Mark this event ID as processed
processedEventIds.add(event.id);
addLogEntry('info', `Received routing event: ${event.id.substring(0, 16)}...`);
try {
// Decrypt the event payload
let decryptedPayload = await decryptRoutingEvent(event);
if (!decryptedPayload) {
addLogEntry('error', `Failed to decrypt event ${event.id.substring(0, 16)}...`);
return;
}
addLogEntry('success', `First decryption successful for event ${event.id.substring(0, 16)}...`);
// Check payload type according to corrected DAEMON.md protocol
if (decryptedPayload.padding !== undefined) {
addLogEntry('info', `Detected Type 2 (Padding Payload) - discarding padding and performing second decryption`);
// This is a padding layer from previous daemon - discard padding and decrypt again
const innerEvent = decryptedPayload.event;
addLogEntry('info', `Discarding padding: "${decryptedPayload.padding}"`);
// DEBUG: Log the inner event BEFORE attempting second decryption
addLogEntry('info', `INNER EVENT BEFORE SECOND DECRYPTION:`);
addLogEntry('info', ` Inner event ID: ${innerEvent.id}`);
addLogEntry('info', ` Inner event pubkey: ${innerEvent.pubkey}`);
addLogEntry('info', ` Inner event content length: ${innerEvent.content.length}`);
addLogEntry('info', ` Inner event full JSON:\n${JSON.stringify(innerEvent, null, 2)}`);
// Second decryption to get the actual routing instructions that were encrypted for me
// Use the inner event's pubkey, not the outer event's pubkey
decryptedPayload = await decryptRoutingEvent(innerEvent);
if (!decryptedPayload) {
addLogEntry('error', `Failed to decrypt inner event ${innerEvent.id.substring(0, 16)}...`);
return;
}
addLogEntry('success', `Second decryption successful - found my original routing instructions from builder`);
} else {
addLogEntry('info', `Detected Type 1 (Routing Payload) - processing routing instructions directly`);
}
// Log the complete decrypted payload without truncation
addLogEntry('info', `Final routing payload:\n${JSON.stringify(decryptedPayload, null, 2)}`);
// Parse routing instructions (these are from the builder, specific to this daemon)
const { event: wrappedEvent, routing } = decryptedPayload;
if (!routing) {
addLogEntry('error', `No routing instructions found in final payload for ${event.id.substring(0, 16)}...`);
return;
}
// DEBUG: Log routing decision
addLogEntry('info', `DEBUG routing.p = "${routing.p}" (type: ${typeof routing.p})`);
if (routing.add_padding_bytes) {
addLogEntry('info', `DEBUG add_padding_bytes = ${routing.add_padding_bytes}`);
}
addLogEntry('info', `DEBUG Will ${routing.p ? 'FORWARD to next hop' : 'POST FINAL EVENT'}`);
if (!validateRoutingInstructions(routing)) {
addLogEntry('error', `Invalid routing instructions in event ${event.id.substring(0, 16)}...`);
return;
}
// Create queue item
const queueItem = {
id: event.id,
wrappedEvent,
routing,
receivedAt: Date.now(),
processAt: Date.now() + (routing.delay * 1000),
status: 'queued'
};
eventQueue.push(queueItem);
updateEventQueue();
addLogEntry('info', `Event queued for processing in ${routing.delay}s: ${event.id.substring(0, 16)}...`);
// Schedule processing
setTimeout(() => processQueuedEvent(queueItem), routing.delay * 1000);
} catch (error) {
addLogEntry('error', `Error processing event ${event.id.substring(0, 16)}...: ${error.message}`);
}
}
// Decrypt routing event using NIP-44 via NIP-07 interface
async function decryptRoutingEvent(event) {
try {
// DEBUG: Log full decryption attempt details
addLogEntry('info', `DEBUG DECRYPTION ATTEMPT:`);
addLogEntry('info', ` Event ID: ${event.id}`);
addLogEntry('info', ` Event pubkey (who signed): ${event.pubkey}`);
addLogEntry('info', ` My thrower pubkey: ${userPubkey}`);
addLogEntry('info', ` Content length: ${event.content.length}`);
addLogEntry('info', ` Full event JSON:\n${JSON.stringify(event, null, 2)}`);
// DEBUG: Check authentication state before attempting decryption
addLogEntry('info', `DEBUG AUTHENTICATION STATE CHECK:`);
// Check if window.nostr exists
addLogEntry('info', ` window.nostr exists: ${!!window.nostr}`);
addLogEntry('info', ` window.nostr constructor: ${window.nostr?.constructor?.name}`);
// Check if NOSTR_LOGIN_LITE exists and getAuthState function
addLogEntry('info', ` window.NOSTR_LOGIN_LITE exists: ${!!window.NOSTR_LOGIN_LITE}`);
addLogEntry('info', ` getAuthState function exists: ${!!window.NOSTR_LOGIN_LITE?.getAuthState}`);
// Get current auth state
let authState = null;
try {
authState = window.NOSTR_LOGIN_LITE?.getAuthState?.() || null;
addLogEntry('info', ` Auth state retrieved: ${!!authState}`);
if (authState) {
addLogEntry('info', ` Auth method: ${authState.method}`);
addLogEntry('info', ` Auth pubkey: ${authState.pubkey}`);
addLogEntry('info', ` Auth timestamp: ${authState.timestamp}`);
addLogEntry('info', ` Auth has secret: ${!!authState.secret}`);
if (authState.secret) {
addLogEntry('info', ` Secret type: ${typeof authState.secret}`);
addLogEntry('info', ` Secret startsWith defined: ${!!authState.secret.startsWith}`);
if (typeof authState.secret === 'string') {
addLogEntry('info', ` Secret length: ${authState.secret.length}`);
addLogEntry('info', ` Secret starts with nsec: ${authState.secret.startsWith('nsec')}`);
}
}
} else {
addLogEntry('error', ` No auth state found - user may not be authenticated`);
}
} catch (authError) {
addLogEntry('error', ` Error getting auth state: ${authError.message}`);
}
// Check window.nostr.nip44 availability
addLogEntry('info', ` window.nostr.nip44 exists: ${!!window.nostr?.nip44}`);
addLogEntry('info', ` window.nostr.nip44.decrypt exists: ${!!window.nostr?.nip44?.decrypt}`);
// DEBUG: Log exact parameters being passed to NIP-44 decrypt
addLogEntry('info', ` CALLING window.nostr.nip44.decrypt() with:`);
addLogEntry('info', ` pubkey parameter: "${event.pubkey}"`);
addLogEntry('info', ` pubkey parameter type: ${typeof event.pubkey}`);
addLogEntry('info', ` pubkey parameter length: ${event.pubkey?.length}`);
addLogEntry('info', ` pubkey parameter undefined: ${event.pubkey === undefined}`);
addLogEntry('info', ` content parameter: "${event.content}"`);
addLogEntry('info', ` content parameter type: ${typeof event.content}`);
addLogEntry('info', ` content parameter length: ${event.content?.length}`);
addLogEntry('info', ` content parameter undefined: ${event.content === undefined}`);
// Additional validation before calling decrypt
if (!event.pubkey) {
throw new Error('Event pubkey is undefined or empty');
}
if (!event.content) {
throw new Error('Event content is undefined or empty');
}
if (!window.nostr) {
throw new Error('window.nostr is not available');
}
if (!window.nostr.nip44) {
throw new Error('window.nostr.nip44 is not available');
}
if (!window.nostr.nip44.decrypt) {
throw new Error('window.nostr.nip44.decrypt function is not available');
}
addLogEntry('info', ` All parameters validated, attempting decryption...`);
// Use window.nostr.nip44.decrypt() which handles the private key internally
const decrypted = await window.nostr.nip44.decrypt(event.pubkey, event.content);
addLogEntry('success', ` Decryption successful! Decrypted length: ${decrypted.length}`);
addLogEntry('info', ` Decrypted preview: ${decrypted.substring(0, 100)}...`);
return JSON.parse(decrypted);
} catch (error) {
addLogEntry('error', ` Decryption failed: ${error.message}`);
addLogEntry('error', ` Error details: ${JSON.stringify(error, null, 2)}`);
console.error('Decryption error:', error);
return null;
}
}
// Validate routing instructions
function validateRoutingInstructions(routing) {
if (!routing || typeof routing !== 'object') return false;
if (!Array.isArray(routing.relays) || routing.relays.length === 0) return false;
if (typeof routing.delay !== 'number' || routing.delay < 0) return false;
if (!routing.audit || typeof routing.audit !== 'string') return false;
return true;
}
// Process a queued event according to corrected DAEMON.md protocol
async function processQueuedEvent(queueItem) {
if (!daemonRunning) return;
addLogEntry('info', `Processing event ${queueItem.id.substring(0, 16)}...`);
queueItem.status = 'processing';
updateEventQueue();
try {
const { wrappedEvent, routing } = queueItem;
// Check if this is final posting or continued routing
addLogEntry('info', `DEBUG Decision point - routing.p = "${routing.p}" (${typeof routing.p})`);
if (routing.add_padding_bytes) {
addLogEntry('info', `DEBUG add_padding_bytes = ${routing.add_padding_bytes} (will add padding when forwarding)`);
}
addLogEntry('info', `DEBUG Will ${routing.p ? 'FORWARD with padding wrapper' : 'POST FINAL EVENT'}`);
if (routing.p) {
// Continue routing to next Superball with padding-only wrapper
await forwardToNextSuperball(wrappedEvent, routing);
} else {
// Final posting - post the wrapped event directly
await postFinalEvent(wrappedEvent, routing.relays);
}
processedEvents++;
document.getElementById('events-processed').textContent = processedEvents;
// Remove from queue
const index = eventQueue.findIndex(item => item.id === queueItem.id);
if (index !== -1) {
eventQueue.splice(index, 1);
updateEventQueue();
}
addLogEntry('success', `Successfully processed event ${queueItem.id.substring(0, 16)}...`);
} catch (error) {
addLogEntry('error', `Failed to process event ${queueItem.id.substring(0, 16)}...: ${error.message}`);
// Mark as failed
queueItem.status = 'failed';
updateEventQueue();
}
}
// Legacy padding function - no longer used in corrected protocol
// Padding is now handled during forwarding with add_padding_bytes
function applyPadding(event, paddingInstruction) {
// This function is deprecated in the corrected protocol
// Padding is now generated during forwarding based on routing.add_padding_bytes
addLogEntry('info', 'Legacy padding function called - no action taken (using new protocol)');
return event;
}
// Forward event to next Superball with padding-only wrapper (DAEMON.md corrected protocol)
async function forwardToNextSuperball(event, routing) {
addLogEntry('info', `Forwarding to next Superball: ${routing.p.substring(0, 16)}...`);
// Create new ephemeral keypair
const ephemeralKey = window.NostrTools.generateSecretKey();
const ephemeralPubkey = window.NostrTools.getPublicKey(ephemeralKey);
// Generate padding based on add_padding_bytes instruction
let paddingData = '';
if (routing.add_padding_bytes && routing.add_padding_bytes > 0) {
// Create random padding of specified length
const chars = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
for (let i = 0; i < routing.add_padding_bytes; i++) {
paddingData += chars.charAt(Math.floor(Math.random() * chars.length));
}
addLogEntry('info', `Generated ${paddingData.length} bytes of padding`);
}
// Create padding-only payload (NEVER create routing instructions - only builder does that)
const paddingPayload = {
event: event, // This is the still-encrypted inner event
padding: paddingData // Padding to discard
};
addLogEntry('info', `DEBUG Creating padding payload with ${paddingData.length} bytes of padding`);
// Log the complete padding payload before encryption
addLogEntry('info', `Padding payload to encrypt:\n${JSON.stringify(paddingPayload, null, 2)}`);
// Encrypt padding payload to next Superball
let ephemeralKeyHex;
if (window.NostrTools.utils && window.NostrTools.utils.bytesToHex) {
ephemeralKeyHex = window.NostrTools.utils.bytesToHex(ephemeralKey);
} else if (window.NostrTools.bytesToHex) {
ephemeralKeyHex = window.NostrTools.bytesToHex(ephemeralKey);
} else {
// Fallback: convert Uint8Array to hex manually
ephemeralKeyHex = Array.from(ephemeralKey).map(b => b.toString(16).padStart(2, '0')).join('');
}
const conversationKey = window.NostrTools.nip44.v2.utils.getConversationKey(
ephemeralKeyHex,
routing.p
);
const encryptedContent = window.NostrTools.nip44.v2.encrypt(
JSON.stringify(paddingPayload),
conversationKey
);
// Create new routing event (forwarding to next hop)
const routingEvent = {
kind: 22222,
content: encryptedContent,
tags: [
['p', routing.p], // Next Superball
['p', routing.audit] // Audit tag (camouflage)
],
created_at: Math.floor(Date.now() / 1000)
};
// Sign with ephemeral key
const signedEvent = window.NostrTools.finalizeEvent(routingEvent, ephemeralKey);
// Log the complete rewrapped event before publishing
addLogEntry('info', `Padding-wrapped event to publish:\n${JSON.stringify(signedEvent, null, 2)}`);
// Publish to specified relays
await publishToRelays(signedEvent, routing.relays);
addLogEntry('success', `Forwarded with padding layer to ${routing.p.substring(0, 16)}... (audit: ${routing.audit.substring(0, 16)}...)`);
}
// Post final event directly to relays
async function postFinalEvent(event, relays) {
addLogEntry('info', `Posting final event to ${relays.length} relays`);
await publishToRelays(event, relays);
addLogEntry('success', `Final event posted to relays: ${event.id.substring(0, 16)}...`);
}
// Publish event to relays (only write-capable relays) with detailed per-relay logging
async function publishToRelays(event, relays) {
const pool = new window.NostrTools.SimplePool();
// Filter to only use relays that don't require auth (write-capable)
const writeRelays = relays.filter(relayUrl => {
const relay = currentRelays.find(r => r.url === relayUrl);
if (!relay) return true; // Default to allowing if relay not found in our config
// Only use relays that don't require auth for publishing
return relay.authStatus === 'no-auth' || relay.authStatus === 'unknown';
});
if (writeRelays.length === 0) {
throw new Error('No write-capable (non-AUTH) relays available for publishing');
}
if (writeRelays.length < relays.length) {
const skippedCount = relays.length - writeRelays.length;
addLogEntry('info', `Skipping ${skippedCount} AUTH-required relays, using ${writeRelays.length} write-capable relays`);
}
// Publish to each relay individually and collect results
const publishPromises = writeRelays.map(relayUrl => {
const promises = pool.publish([relayUrl], event); // Returns array of promises
return promises[0] // Get the first (and only) promise for this relay
.then(result => ({ relayUrl, success: true, result }))
.catch(error => ({ relayUrl, success: false, error: error.message }));
});
try {
// Wait for all publishing attempts to complete
const results = await Promise.allSettled(publishPromises);
let successCount = 0;
let failureCount = 0;
const successfulRelays = [];
const failedRelays = [];
// Process and log each individual result
results.forEach((promiseResult, index) => {
if (promiseResult.status === 'fulfilled') {
const { relayUrl, success, result, error } = promiseResult.value;
if (success) {
successCount++;
successfulRelays.push(relayUrl);
addLogEntry('success', `✅ Published successfully to ${relayUrl}`);
} else {
failureCount++;
failedRelays.push({ relayUrl, error });
addLogEntry('error', `❌ Failed to publish to ${relayUrl}: ${error}`);
}
} else {
// This shouldn't happen since we catch errors above, but handle it just in case
const relayUrl = writeRelays[index];
failureCount++;
failedRelays.push({ relayUrl, error: promiseResult.reason?.message || 'Unknown error' });
addLogEntry('error', `❌ Failed to publish to ${relayUrl}: ${promiseResult.reason?.message || 'Unknown error'}`);
}
});
// Log summary
addLogEntry('info', `Publishing summary: ${successCount} successful, ${failureCount} failed out of ${writeRelays.length} total relays`);
if (successCount > 0) {
addLogEntry('success', `Successfully published to: ${successfulRelays.join(', ')}`);
// Log full published event (only once for successful publications)
addLogEntry('info', `Full published event:\n${JSON.stringify(event, null, 2)}`);
}
if (failureCount > 0) {
const failureDetails = failedRelays.map(f => `${f.relayUrl}: ${f.error}`).join(', ');
addLogEntry('error', `Failed to publish to: ${failureDetails}`);
}
// Maintain the same behavior as before: succeed if at least one relay succeeded
if (successCount === 0) {
throw new Error(`Failed to publish to any write-capable relay. Attempted: ${writeRelays.join(', ')}`);
}
} finally {
pool.close(writeRelays);
}
}
// Update daemon UI
function updateDaemonUI() {
const button = document.getElementById('daemon-toggle');
const buttonText = document.getElementById('daemon-button-text');
const statusText = document.getElementById('daemon-status-text');
if (daemonRunning) {
button.classList.add('running');
buttonText.textContent = 'Stop Thrower';
statusText.textContent = 'Running';
statusText.style.color = '#28a745';
} else {
button.classList.remove('running');
buttonText.textContent = 'Start Thrower';
statusText.textContent = 'Stopped';
statusText.style.color = '#dc3545';
// Reset counters when stopped
document.getElementById('monitoring-relays').textContent = '0';
document.getElementById('events-queued').textContent = '0';
}
}
// Update event queue display
function updateEventQueue() {
const container = document.getElementById('event-queue');
if (eventQueue.length === 0) {
container.innerHTML = '<div class="info status-message">No events in queue</div>';
document.getElementById('events-queued').textContent = '0';
return;
}
container.innerHTML = '';
document.getElementById('events-queued').textContent = eventQueue.length;
eventQueue.forEach(item => {
const div = document.createElement('div');
div.className = `event-queue-item ${item.status}`;
const timeLeft = Math.max(0, Math.ceil((item.processAt - Date.now()) / 1000));
const statusText = item.status === 'queued' ?
`Queued - Processing in ${timeLeft}s` :
item.status.charAt(0).toUpperCase() + item.status.slice(1);
div.innerHTML = `
<div><strong>Event:</strong> ${item.id.substring(0, 32)}...</div>
<div><strong>Status:</strong> ${statusText}</div>
<div><strong>Target Relays:</strong> ${item.routing.relays.length}</div>
<div><strong>Delay:</strong> ${item.routing.delay}s</div>
${item.routing.padding ? `<div><strong>Padding:</strong> ${item.routing.padding}</div>` : ''}
${item.routing.p ? `<div><strong>Next Hop:</strong> ${item.routing.p.substring(0, 16)}...</div>` : '<div><strong>Final Posting</strong></div>'}
`;
container.appendChild(div);
});
}
// Add log entry
function addLogEntry(type, message) {
const timestamp = new Date().toLocaleTimeString();
const entry = {
timestamp,
type,
message
};
logEntries.push(entry);
// Keep only last 100 entries
if (logEntries.length > 100) {
logEntries.shift();
}
updateProcessingLog();
}
// Update processing log display
function updateProcessingLog() {
const container = document.getElementById('processing-log');
if (logEntries.length === 0) {
container.innerHTML = '<div class="info status-message">Thrower stopped - no activity</div>';
return;
}
container.innerHTML = '';
// Show most recent entries first
logEntries.slice().reverse().forEach(entry => {
const div = document.createElement('div');
div.className = `log-entry ${entry.type}`;
// Handle multiline messages (like JSON) with proper formatting
const messageContent = entry.message.includes('\n') ?
`<span class="log-timestamp">${entry.timestamp}</span><br><pre style="margin: 5px 0; font-family: monospace; font-size: 11px; white-space: pre-wrap;">${entry.message}</pre>` :
`<span class="log-timestamp">${entry.timestamp}</span> ${entry.message}`;
div.innerHTML = messageContent;
container.appendChild(div);
});
// Auto-scroll to top (most recent)
container.scrollTop = 0;
}
// Update queue timers every second
setInterval(() => {
if (daemonRunning && eventQueue.length > 0) {
updateEventQueue();
}
}, 1000);
document.addEventListener('DOMContentLoaded', () => {
setTimeout(initializeApp, 100);
});
</script>
</body>
</html>