1654 lines
59 KiB
HTML
1654 lines
59 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>
|
|
<style>
|
|
body {
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
margin: 0;
|
|
padding: 20px;
|
|
background: #ffffff;
|
|
min-height: 100vh;
|
|
color: #000000;
|
|
}
|
|
|
|
.section {
|
|
margin: 20px 0;
|
|
border: 1px solid #ddd;
|
|
padding: 15px;
|
|
border-radius: 5px;
|
|
}
|
|
|
|
.section h2 {
|
|
margin: 0 0 15px 0;
|
|
font-size: 18px;
|
|
}
|
|
|
|
input, textarea, button, select {
|
|
width: 100%;
|
|
padding: 8px;
|
|
margin: 5px 0;
|
|
border: 1px solid #ccc;
|
|
font-family: inherit;
|
|
border-radius: 3px;
|
|
}
|
|
|
|
button {
|
|
background: #f0f0f0;
|
|
cursor: pointer;
|
|
width: auto;
|
|
padding: 10px 20px;
|
|
}
|
|
|
|
button:hover {
|
|
background: #e0e0e0;
|
|
}
|
|
|
|
.button-primary {
|
|
background: #007bff;
|
|
color: white;
|
|
}
|
|
|
|
.button-primary:hover {
|
|
background: #0056b3;
|
|
}
|
|
|
|
.button-danger {
|
|
background: #dc3545;
|
|
color: white;
|
|
}
|
|
|
|
.button-danger:hover {
|
|
background: #c82333;
|
|
}
|
|
|
|
.input-group {
|
|
margin: 10px 0;
|
|
}
|
|
|
|
label {
|
|
display: block;
|
|
font-weight: bold;
|
|
margin-bottom: 3px;
|
|
}
|
|
|
|
|
|
.relay-item {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
margin: 5px 0;
|
|
padding: 8px;
|
|
border: 1px solid #eee;
|
|
border-radius: 3px;
|
|
background: #f9f9f9;
|
|
}
|
|
|
|
.relay-url {
|
|
flex: 1;
|
|
font-family: monospace;
|
|
font-size: 12px;
|
|
word-break: break-all;
|
|
}
|
|
|
|
.relay-type {
|
|
min-width: 80px;
|
|
font-size: 12px;
|
|
color: #666;
|
|
text-align: center;
|
|
}
|
|
|
|
.relay-actions {
|
|
display: flex;
|
|
gap: 5px;
|
|
}
|
|
|
|
.relay-actions button {
|
|
padding: 4px 8px;
|
|
font-size: 12px;
|
|
width: auto;
|
|
}
|
|
|
|
.hidden {
|
|
display: none;
|
|
}
|
|
|
|
.status-message {
|
|
padding: 10px;
|
|
margin: 10px 0;
|
|
border-radius: 3px;
|
|
}
|
|
|
|
.success {
|
|
background: #d4edda;
|
|
color: #155724;
|
|
border: 1px solid #c3e6cb;
|
|
}
|
|
|
|
.error {
|
|
background: #f8d7da;
|
|
color: #721c24;
|
|
border: 1px solid #f5c6cb;
|
|
}
|
|
|
|
.info {
|
|
background: #d1ecf1;
|
|
color: #0c5460;
|
|
border: 1px solid #bee5eb;
|
|
}
|
|
|
|
.add-relay-form {
|
|
display: flex;
|
|
gap: 10px;
|
|
align-items: end;
|
|
}
|
|
|
|
.add-relay-form input {
|
|
flex: 1;
|
|
}
|
|
|
|
.add-relay-form select {
|
|
width: 100px;
|
|
}
|
|
|
|
.add-relay-form button {
|
|
width: auto;
|
|
margin: 5px 0;
|
|
}
|
|
|
|
.pubkey-display {
|
|
font-family: monospace;
|
|
font-size: 12px;
|
|
word-break: break-all;
|
|
background: #f8f9fa;
|
|
padding: 5px;
|
|
border-radius: 3px;
|
|
}
|
|
|
|
.action-buttons {
|
|
display: flex;
|
|
gap: 10px;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.daemon-control {
|
|
margin: 15px 0;
|
|
}
|
|
|
|
#daemon-toggle {
|
|
font-size: 16px;
|
|
padding: 12px 24px;
|
|
margin-bottom: 15px;
|
|
border-radius: 5px;
|
|
}
|
|
|
|
#daemon-toggle.running {
|
|
background: #dc3545;
|
|
}
|
|
|
|
#daemon-toggle.running:hover {
|
|
background: #c82333;
|
|
}
|
|
|
|
#daemon-status {
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr;
|
|
gap: 10px;
|
|
background: #f8f9fa;
|
|
padding: 10px;
|
|
border-radius: 3px;
|
|
font-size: 14px;
|
|
}
|
|
|
|
.event-queue-item {
|
|
background: #fff3cd;
|
|
border: 1px solid #ffeaa7;
|
|
padding: 10px;
|
|
margin: 5px 0;
|
|
border-radius: 3px;
|
|
font-family: monospace;
|
|
font-size: 12px;
|
|
}
|
|
|
|
.event-queue-item.processing {
|
|
background: #d1ecf1;
|
|
border-color: #bee5eb;
|
|
}
|
|
|
|
.log-entry {
|
|
padding: 8px;
|
|
margin: 2px 0;
|
|
border-left: 3px solid #ddd;
|
|
background: #f8f9fa;
|
|
font-family: monospace;
|
|
font-size: 12px;
|
|
}
|
|
|
|
.log-entry.success {
|
|
border-left-color: #28a745;
|
|
background: #d4edda;
|
|
}
|
|
|
|
.log-entry.error {
|
|
border-left-color: #dc3545;
|
|
background: #f8d7da;
|
|
}
|
|
|
|
.log-entry.info {
|
|
border-left-color: #17a2b8;
|
|
background: #d1ecf1;
|
|
}
|
|
|
|
.log-timestamp {
|
|
color: #666;
|
|
font-weight: bold;
|
|
}
|
|
|
|
#processing-log {
|
|
max-height: 300px;
|
|
overflow-y: auto;
|
|
border: 1px solid #ddd;
|
|
border-radius: 3px;
|
|
}
|
|
</style>
|
|
</head>
|
|
|
|
<body>
|
|
|
|
<div id="login-section">
|
|
<!-- Login UI if needed -->
|
|
</div>
|
|
|
|
<div id="main-content" class="hidden">
|
|
<h1>Superball Thrower Setup</h1>
|
|
|
|
<!-- Thrower Identity & Information Document Section (SUP-06) -->
|
|
<div class="section">
|
|
<h2>Thrower Identity & Information Document (SUP-06)</h2>
|
|
<p>Configure your Thrower's identity and public information document. This will be published as a kind 12222 event and automatically refreshed when the daemon is running.</p>
|
|
|
|
<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;">
|
|
<div style="display: flex; align-items: center; gap: 15px; margin-bottom: 15px;">
|
|
<img id="thrower-icon" alt="Thrower Icon" style="display: none; width: 60px; height: 60px; border-radius: 50%; object-fit: cover;">
|
|
<div style="flex: 1;">
|
|
<div><strong>Thrower Name:</strong> <span id="thrower-display-name">Loading...</span></div>
|
|
<div><strong>Description:</strong> <span id="thrower-display-description">Loading...</span></div>
|
|
</div>
|
|
</div>
|
|
<div><strong>Pubkey:</strong></div>
|
|
<div class="pubkey-display" id="profile-pubkey"></div>
|
|
<div><strong>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 class="action-buttons">
|
|
<button onclick="toggleEditThrowerInfo()">Edit Thrower Info</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>
|
|
|
|
<!-- Relay Management Section -->
|
|
<div class="section">
|
|
<h2>Relay Configuration</h2>
|
|
<p>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-list">
|
|
<!-- Relay 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>
|
|
</div>
|
|
|
|
<div id="relay-status"></div>
|
|
</div>
|
|
|
|
<!-- Superball Daemon Control Section -->
|
|
<div class="section">
|
|
<h2>Superball Daemon</h2>
|
|
<div class="daemon-control">
|
|
<button id="daemon-toggle" class="button-primary" onclick="toggleDaemon()">
|
|
<span id="daemon-button-text">Start Daemon</span>
|
|
</button>
|
|
<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>
|
|
</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">Daemon stopped - no activity</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Load the official nostr-tools bundle first -->
|
|
<script src="./nostr.bundle.js"></script>
|
|
|
|
<!-- Load NOSTR_LOGIN_LITE main library -->
|
|
<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 = {};
|
|
|
|
// Daemon variables
|
|
let daemonRunning = false;
|
|
let monitoringWebSockets = [];
|
|
let eventQueue = [];
|
|
let processedEvents = 0;
|
|
let logEntries = [];
|
|
let subscriptionId = null;
|
|
|
|
// SUP-06 variables
|
|
let throwerInfoRefreshInterval = null;
|
|
let lastThrowerInfoPublish = null;
|
|
|
|
// Initialize NOSTR_LOGIN_LITE
|
|
async function initializeApp() {
|
|
try {
|
|
await window.NOSTR_LOGIN_LITE.init({
|
|
theme: 'default',
|
|
darkMode: false,
|
|
methods: {
|
|
extension: true,
|
|
local: true,
|
|
seedphrase: true,
|
|
connect: true,
|
|
remote: true,
|
|
otp: true
|
|
},
|
|
floatingTab: {
|
|
enabled: true,
|
|
hPosition: .98,
|
|
vPosition: 0,
|
|
getUserInfo: true,
|
|
getUserRelay: ['wss://relay.laantungir.net'],
|
|
appearance: {
|
|
style: 'minimal',
|
|
theme: 'auto',
|
|
icon: '',
|
|
text: 'Login',
|
|
iconOnly: false
|
|
},
|
|
behavior: {
|
|
hideWhenAuthenticated: false,
|
|
showUserInfo: true,
|
|
autoSlide: false,
|
|
persistent: false
|
|
},
|
|
animation: {
|
|
slideDirection: 'right'
|
|
}
|
|
},
|
|
debug: true
|
|
});
|
|
|
|
nlLite = window.NOSTR_LOGIN_LITE;
|
|
console.log('SUCCESS', 'NOSTR_LOGIN_LITE initialized successfully');
|
|
|
|
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}`);
|
|
|
|
document.getElementById('main-content').classList.remove('hidden');
|
|
document.getElementById('profile-pubkey').textContent = pubkey;
|
|
|
|
loadRelayList();
|
|
loadThrowerInfo();
|
|
|
|
// Update profile display with data from nostr-lite.js
|
|
setTimeout(updateProfileDisplay, 1000);
|
|
|
|
} else if (error) {
|
|
console.log('ERROR', `Authentication error: ${error}`);
|
|
}
|
|
}
|
|
|
|
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] || ''
|
|
}));
|
|
} else {
|
|
console.log('INFO', 'No relay list found, using defaults');
|
|
currentRelays = [
|
|
{ url: 'wss://relay.laantungir.net', type: '' }
|
|
];
|
|
}
|
|
|
|
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() {
|
|
const container = document.getElementById('relay-list');
|
|
container.innerHTML = '';
|
|
|
|
if (currentRelays.length === 0) {
|
|
container.innerHTML = '<div class="info status-message">No relays configured. Add relays above.</div>';
|
|
return;
|
|
}
|
|
|
|
currentRelays.forEach((relay, index) => {
|
|
const relayItem = document.createElement('div');
|
|
relayItem.className = 'relay-item';
|
|
relayItem.innerHTML = `
|
|
<div class="relay-url">${relay.url}</div>
|
|
<div class="relay-type">${relay.type || 'both'}</div>
|
|
<div class="relay-actions">
|
|
<button onclick="removeRelay(${index})">Remove</button>
|
|
</div>
|
|
`;
|
|
container.appendChild(relayItem);
|
|
});
|
|
}
|
|
|
|
// 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 using Promise.any as per nostr-tools example
|
|
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 {
|
|
// Use Promise.any() to publish to all relays - succeeds if ANY relay accepts
|
|
console.log('DEBUG: Using Promise.any() to publish relay list to all relays simultaneously');
|
|
await Promise.any(pool.publish(relaysToUse, signedEvent));
|
|
|
|
pool.close(relaysToUse);
|
|
|
|
showStatus('relay-status', 'Relay configuration saved successfully!', 'success');
|
|
console.log('SUCCESS', 'Relay list published to at least one relay');
|
|
|
|
} catch (aggregateError) {
|
|
pool.close(relaysToUse);
|
|
|
|
console.log('ERROR: All relay list publish attempts failed:', aggregateError.errors);
|
|
|
|
// Extract individual error messages
|
|
const errorMessages = aggregateError.errors.map((err, index) =>
|
|
`${relaysToUse[index]}: ${err.message}`
|
|
).join(', ');
|
|
|
|
throw new Error('Failed to publish relay list to any relay: ' + errorMessages);
|
|
}
|
|
|
|
} 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);
|
|
}
|
|
}
|
|
|
|
// ============ 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 {
|
|
await Promise.any(pool.publish(relaysToUse, signedEvent));
|
|
pool.close(relaysToUse);
|
|
|
|
// 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();
|
|
|
|
await Promise.any(pool.publish(relaysToUse, signedEvent));
|
|
pool.close(relaysToUse);
|
|
|
|
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 DAEMON FUNCTIONALITY ============
|
|
|
|
// Toggle daemon on/off
|
|
function toggleDaemon() {
|
|
if (daemonRunning) {
|
|
stopDaemon();
|
|
} else {
|
|
startDaemon();
|
|
}
|
|
}
|
|
|
|
// Start the Superball daemon
|
|
async function startDaemon() {
|
|
if (!userPubkey || currentRelays.length === 0) {
|
|
addLogEntry('error', 'Cannot start daemon: Profile not loaded or no relays configured');
|
|
showStatus('relay-status', 'Please configure profile and relays before starting daemon', 'error');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
addLogEntry('info', 'Starting Superball daemon...');
|
|
|
|
daemonRunning = true;
|
|
updateDaemonUI();
|
|
|
|
// Get relay URLs for monitoring
|
|
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;
|
|
|
|
// Start auto-publishing Thrower Info Document
|
|
startThrowerInfoAutoPublish();
|
|
|
|
} catch (error) {
|
|
addLogEntry('error', `Failed to start daemon: ${error.message}`);
|
|
daemonRunning = false;
|
|
updateDaemonUI();
|
|
}
|
|
}
|
|
|
|
// Stop the Superball daemon
|
|
function stopDaemon() {
|
|
addLogEntry('info', 'Stopping Superball daemon...');
|
|
|
|
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 = [];
|
|
|
|
// Stop auto-publishing Thrower Info Document
|
|
stopThrowerInfoAutoPublish();
|
|
|
|
updateDaemonUI();
|
|
updateEventQueue();
|
|
|
|
addLogEntry('success', 'Daemon 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) {
|
|
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 daemon pubkey: ${userPubkey}`);
|
|
addLogEntry('info', ` Content length: ${event.content.length}`);
|
|
addLogEntry('info', ` Full event JSON:\n${JSON.stringify(event, null, 2)}`);
|
|
|
|
// 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', ` content parameter: "${event.content}"`);
|
|
addLogEntry('info', ` content parameter length: ${event.content.length}`);
|
|
addLogEntry('info', ` content parameter type: ${typeof event.content}`);
|
|
|
|
// 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
|
|
async function publishToRelays(event, relays) {
|
|
const pool = new window.NostrTools.SimplePool();
|
|
|
|
try {
|
|
await Promise.any(pool.publish(relays, event));
|
|
addLogEntry('success', `Published to relays: ${relays.join(', ')}`);
|
|
|
|
// Log full published event
|
|
addLogEntry('info', `Full published event:\n${JSON.stringify(event, null, 2)}`);
|
|
} catch (aggregateError) {
|
|
const errorMessages = aggregateError.errors.map((err, index) =>
|
|
`${relays[index]}: ${err.message}`
|
|
).join(', ');
|
|
throw new Error('Failed to publish to any relay: ' + errorMessages);
|
|
} finally {
|
|
pool.close(relays);
|
|
}
|
|
}
|
|
|
|
// 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 Daemon';
|
|
statusText.textContent = 'Running';
|
|
statusText.style.color = '#28a745';
|
|
} else {
|
|
button.classList.remove('running');
|
|
buttonText.textContent = 'Start Daemon';
|
|
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">Daemon 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>
|