v0.4.11 - Fixed nasty DM bug

This commit is contained in:
Your Name
2025-10-06 10:06:24 -04:00
parent d5350d7c30
commit f6d13d4318
11 changed files with 1376 additions and 753 deletions

View File

@@ -43,14 +43,12 @@
const disconnectRelayBtn = document.getElementById('disconnect-relay-btn');
const testWebSocketBtn = document.getElementById('test-websocket-btn');
const configDisplay = document.getElementById('config-display');
const configViewMode = document.getElementById('config-view-mode');
const configEditMode = document.getElementById('config-edit-mode');
const configTableBody = document.getElementById('config-table-body');
const configForm = document.getElementById('config-form');
const copyConfigBtn = document.getElementById('copy-config-btn');
const editConfigBtn = document.getElementById('edit-config-btn');
const saveConfigBtn = document.getElementById('save-config-btn');
const cancelEditBtn = document.getElementById('cancel-edit-btn');
// NIP-17 DM elements
const dmOutbox = document.getElementById('dm-outbox');
const dmInbox = document.getElementById('dm-inbox');
const sendDmBtn = document.getElementById('send-dm-btn');
// Utility functions
function log(message, type = 'INFO') {
@@ -63,6 +61,33 @@
// UI logging removed - using console only
}
// Utility functions
function log(message, type = 'INFO') {
const timestamp = new Date().toISOString().split('T')[1].split('.')[0];
const logMessage = `${timestamp} [${type}]: ${message}`;
// Always log to browser console so we don't lose logs on refresh
console.log(logMessage);
// UI logging removed - using console only
}
// NIP-59 helper: randomize created_at to thwart time-analysis (past 2 days)
function randomNow() {
const TWO_DAYS = 2 * 24 * 60 * 60; // 172800 seconds
const now = Math.round(Date.now() / 1000);
return Math.round(now - Math.random() * TWO_DAYS);
}
// Safe JSON parse with error handling
function safeJsonParse(jsonString) {
try {
return JSON.parse(jsonString);
} catch (error) {
console.error('JSON parse error:', error);
return null;
}
}
// ================================
// NIP-11 RELAY CONNECTION FUNCTIONS
// ================================
@@ -582,10 +607,12 @@
function updateAdminSectionsVisibility() {
const divConfig = document.getElementById('div_config');
const authRulesSection = document.getElementById('authRulesSection');
const nip17DMSection = document.getElementById('nip17DMSection');
const shouldShow = isLoggedIn && isRelayConnected;
if (divConfig) divConfig.style.display = shouldShow ? 'block' : 'none';
if (authRulesSection) authRulesSection.style.display = shouldShow ? 'block' : 'none';
if (nip17DMSection) nip17DMSection.style.display = shouldShow ? 'block' : 'none';
}
// Show main interface after login
@@ -736,30 +763,105 @@
subscriptionId = generateSubId();
console.log(`Generated subscription ID: ${subscriptionId}`);
// Subscribe to kind 23457 events (admin response events)
console.log(`User pubkey ${userPubkey}`)
// Subscribe to kind 23457 events (admin response events), kind 4 (NIP-04 DMs), and kind 1059 (NIP-17 GiftWrap)
const subscription = relayPool.subscribeMany([url], [{
since: Math.floor(Date.now() / 1000),
kinds: [23457],
authors: [getRelayPubkey()], // Only listen to responses from the relay
"#p": [userPubkey], // Only responses directed to this user
limit: 50
}, {
since: Math.floor(Date.now() / 1000),
kinds: [4], // NIP-04 Direct Messages
authors: [getRelayPubkey()], // Only listen to DMs from the relay
"#p": [userPubkey], // Only DMs directed to this user
limit: 50
}, {
kinds: [1059], // NIP-17 GiftWrap events
"#p": [userPubkey], // Only GiftWrap events addressed to this user
limit: 50
}], {
onevent(event) {
console.log('=== ADMIN RESPONSE EVENT RECEIVED VIA SIMPLEPOOL ===');
async onevent(event) {
console.log('=== EVENT RECEIVED VIA SIMPLEPOOL ===');
console.log('Event data:', event);
console.log('Event kind:', event.kind);
console.log('Event tags:', event.tags);
console.log('Event pubkey:', event.pubkey);
console.log('=== END ADMIN RESPONSE ===');
console.log('=== END EVENT ===');
// Log all received messages for testing
if (typeof logTestEvent === 'function') {
logTestEvent('RECV', `Admin response event: ${JSON.stringify(event)}`, 'EVENT');
// Handle NIP-04 DMs
if (event.kind === 4) {
console.log('=== NIP-04 DM RECEIVED ===');
try {
// Decrypt the DM content
const decryptedContent = await window.nostr.nip04.decrypt(event.pubkey, event.content);
log(`Received NIP-04 DM from relay: ${decryptedContent.substring(0, 50)}...`, 'INFO');
// Add to inbox
const timestamp = new Date(event.created_at * 1000).toLocaleString();
addMessageToInbox('received', decryptedContent, timestamp);
// Log for testing
if (typeof logTestEvent === 'function') {
logTestEvent('RECV', `NIP-04 DM: ${decryptedContent}`, 'DM');
}
} catch (decryptError) {
log(`Failed to decrypt NIP-04 DM: ${decryptError.message}`, 'ERROR');
if (typeof logTestEvent === 'function') {
logTestEvent('ERROR', `Failed to decrypt DM: ${decryptError.message}`, 'DM');
}
}
return;
}
// Process admin response event
processAdminResponse(event);
// Handle NIP-17 GiftWrap DMs
if (event.kind === 1059) {
console.log('=== NIP-17 GIFTWRAP RECEIVED ===');
try {
// Step 1: Unwrap gift wrap to get seal
const sealJson = await window.nostr.nip44.decrypt(event.pubkey, event.content);
const seal = safeJsonParse(sealJson);
if (!seal || seal.kind !== 13) {
throw new Error('Unwrapped content is not a valid seal (kind 13)');
}
// Step 2: Unseal to get rumor
const rumorJson = await window.nostr.nip44.decrypt(seal.pubkey, seal.content);
const rumor = safeJsonParse(rumorJson);
if (!rumor || rumor.kind !== 14) {
throw new Error('Unsealed content is not a valid rumor (kind 14)');
}
log(`Received NIP-17 DM from relay: ${rumor.content.substring(0, 50)}...`, 'INFO');
// Add to inbox
const timestamp = new Date(event.created_at * 1000).toLocaleString();
addMessageToInbox('received', rumor.content, timestamp);
// Log for testing
if (typeof logTestEvent === 'function') {
logTestEvent('RECV', `NIP-17 DM: ${rumor.content}`, 'DM');
}
} catch (unwrapError) {
log(`Failed to unwrap NIP-17 DM: ${unwrapError.message}`, 'ERROR');
if (typeof logTestEvent === 'function') {
logTestEvent('ERROR', `Failed to unwrap DM: ${unwrapError.message}`, 'DM');
}
}
return;
}
// Handle admin response events (kind 23457)
if (event.kind === 23457) {
// Log all received messages for testing
if (typeof logTestEvent === 'function') {
logTestEvent('RECV', `Admin response event: ${JSON.stringify(event)}`, 'EVENT');
}
// Process admin response event
processAdminResponse(event);
}
},
oneose() {
console.log('EOSE received - End of stored events');
@@ -1270,32 +1372,61 @@
// Clear existing table
configTableBody.innerHTML = '';
// Display basic event info
const basicInfo = [
['Event ID', event.id],
['Public Key', event.pubkey],
['Created At', new Date(event.created_at * 1000).toISOString()],
['Kind', event.kind],
['Content', event.content]
];
console.log(`Adding ${basicInfo.length} basic info rows`);
basicInfo.forEach(([key, value]) => {
const row = document.createElement('tr');
row.innerHTML = `<td>${key}</td><td>${value}</td><td>-</td>`;
configTableBody.appendChild(row);
});
// Display tags
console.log(`Processing ${event.tags.length} tags`);
event.tags.forEach(tag => {
// Display tags (editable configuration parameters only)
console.log(`Processing ${event.tags.length} configuration parameters`);
event.tags.forEach((tag, index) => {
if (tag.length >= 2) {
const row = document.createElement('tr');
row.innerHTML = `<td>${tag[0]}</td><td>${tag[1]}</td><td>-</td>`;
const key = tag[0];
const value = tag[1];
// Create editable input for value
const valueInput = document.createElement('input');
valueInput.type = 'text';
valueInput.value = value;
valueInput.className = 'config-value-input';
valueInput.dataset.key = key;
valueInput.dataset.originalValue = value;
valueInput.dataset.rowIndex = index;
// Create clickable Actions cell
const actionsCell = document.createElement('td');
actionsCell.className = 'config-actions-cell';
actionsCell.textContent = 'SAVE';
actionsCell.dataset.key = key;
actionsCell.dataset.originalValue = value;
actionsCell.dataset.rowIndex = index;
// Initially hide the SAVE text
actionsCell.style.color = 'transparent';
// Show SAVE text and make clickable when value changes
valueInput.addEventListener('input', function() {
if (this.value !== this.dataset.originalValue) {
actionsCell.style.color = 'var(--primary-color)';
actionsCell.style.cursor = 'pointer';
actionsCell.onclick = () => saveIndividualConfig(key, valueInput.value, valueInput.dataset.originalValue, actionsCell);
} else {
actionsCell.style.color = 'transparent';
actionsCell.style.cursor = 'default';
actionsCell.onclick = null;
}
});
row.innerHTML = `<td>${key}</td><td></td>`;
row.cells[1].appendChild(valueInput);
row.appendChild(actionsCell);
configTableBody.appendChild(row);
}
});
// Show message if no configuration parameters found
if (event.tags.length === 0) {
const row = document.createElement('tr');
row.innerHTML = `<td colspan="3" style="text-align: center; font-style: italic;">No configuration parameters found</td>`;
configTableBody.appendChild(row);
}
console.log('Configuration display completed successfully');
updateConfigStatus(true);
@@ -1305,195 +1436,98 @@
}
}
// Configuration editing functions
function generateConfigForm(event) {
if (!event || !event.tags) {
console.log('No configuration event to edit');
return;
}
configForm.innerHTML = '';
// Define field types and validation for different config parameters (aligned with README.md)
const fieldTypes = {
'auth_enabled': 'boolean',
'nip42_auth_required': 'boolean',
'nip40_expiration_enabled': 'boolean',
'max_connections': 'number',
'pow_min_difficulty': 'number',
'nip42_challenge_timeout': 'number',
'max_subscriptions_per_client': 'number',
'max_event_tags': 'number',
'max_content_length': 'number'
};
const descriptions = {
'relay_pubkey': 'Relay Public Key (Read-only)',
'auth_enabled': 'Enable Authentication',
'nip42_auth_required': 'Enable NIP-42 Cryptographic Authentication',
'nip42_auth_required_kinds': 'Event Kinds Requiring NIP-42 Auth',
'nip42_challenge_timeout': 'NIP-42 Challenge Expiration Seconds',
'max_connections': 'Maximum Connections',
'relay_description': 'Relay Description',
'relay_contact': 'Relay Contact',
'pow_min_difficulty': 'Minimum Proof-of-Work Difficulty',
'nip40_expiration_enabled': 'Enable Event Expiration',
'max_subscriptions_per_client': 'Max Subscriptions per Client',
'max_event_tags': 'Maximum Tags per Event',
'max_content_length': 'Maximum Event Content Length'
};
// Process configuration tags (no d tag filtering for ephemeral events)
const configData = {};
event.tags.forEach(tag => {
if (tag.length >= 2) {
configData[tag[0]] = tag[1];
}
});
// Create form fields for each configuration parameter
Object.entries(configData).forEach(([key, value]) => {
const fieldType = fieldTypes[key] || 'text';
const description = descriptions[key] || key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
const fieldGroup = document.createElement('div');
fieldGroup.className = 'input-group';
const label = document.createElement('label');
label.textContent = description;
label.setAttribute('for', `config-${key}`);
let input;
if (fieldType === 'boolean') {
input = document.createElement('select');
input.innerHTML = `
<option value="true" ${value === 'true' ? 'selected' : ''}>true</option>
<option value="false" ${value === 'false' ? 'selected' : ''}>false</option>
`;
} else if (fieldType === 'number') {
input = document.createElement('input');
input.type = 'number';
input.value = value;
input.min = '0';
} else {
input = document.createElement('input');
input.type = 'text';
input.value = value;
}
input.id = `config-${key}`;
input.name = key;
// Make relay_pubkey read-only
if (key === 'relay_pubkey' || key === 'd') {
input.disabled = true;
}
fieldGroup.appendChild(label);
fieldGroup.appendChild(input);
configForm.appendChild(fieldGroup);
});
console.log('Configuration form generated');
}
function enterEditMode() {
if (!currentConfig) {
console.log('No configuration loaded to edit');
return;
}
generateConfigForm(currentConfig);
configViewMode.classList.add('hidden');
configEditMode.classList.remove('hidden');
console.log('Entered edit mode');
}
function exitEditMode() {
configViewMode.classList.remove('hidden');
configEditMode.classList.add('hidden');
configForm.innerHTML = '';
console.log('Exited edit mode');
}
async function saveConfiguration() {
// Save individual configuration parameter
async function saveIndividualConfig(key, newValue, originalValue, actionsCell) {
if (!isLoggedIn || !userPubkey) {
console.log('Must be logged in to save configuration');
log('Must be logged in to save configuration', 'ERROR');
return;
}
if (!currentConfig) {
console.log('No current configuration to update');
log('No current configuration to update', 'ERROR');
return;
}
// Don't save if value hasn't changed
if (newValue === originalValue) {
return;
}
try {
console.log('Building configuration update command...');
log(`Saving individual config: ${key} = ${newValue}`, 'INFO');
// Collect form data
const formInputs = configForm.querySelectorAll('input, select');
const configObjects = [];
// Determine data type based on key name
let dataType = 'string';
if (['max_connections', 'pow_min_difficulty', 'nip42_challenge_timeout', 'max_subscriptions_per_client', 'max_event_tags', 'max_content_length'].includes(key)) {
dataType = 'integer';
} else if (['auth_enabled', 'nip42_auth_required', 'nip40_expiration_enabled'].includes(key)) {
dataType = 'boolean';
}
// Process each form input as a config object
formInputs.forEach(input => {
if (!input.disabled && input.name && input.name !== 'd' && input.name !== 'relay_pubkey') {
// Determine data type based on input type
let dataType = 'string';
if (input.type === 'number') {
dataType = 'integer';
} else if (input.tagName === 'SELECT' && (input.value === 'true' || input.value === 'false')) {
dataType = 'boolean';
}
// Determine category based on key name
let category = 'general';
if (key.startsWith('relay_')) {
category = 'relay';
} else if (key.startsWith('nip40_')) {
category = 'expiration';
} else if (key.startsWith('nip42_') || key.startsWith('auth_')) {
category = 'authentication';
} else if (key.startsWith('pow_')) {
category = 'proof_of_work';
} else if (key.startsWith('max_')) {
category = 'limits';
}
// Determine category based on key name
let category = 'general';
const key = input.name;
if (key.startsWith('relay_')) {
category = 'relay';
} else if (key.startsWith('nip40_')) {
category = 'expiration';
} else if (key.startsWith('nip42_') || key.startsWith('auth_')) {
category = 'authentication';
} else if (key.startsWith('pow_')) {
category = 'proof_of_work';
} else if (key.startsWith('max_')) {
category = 'limits';
}
const configObj = {
key: key,
value: newValue,
data_type: dataType,
category: category
};
configObjects.push({
key: key,
value: input.value,
data_type: dataType,
category: category
});
// Update cell during save
actionsCell.textContent = 'SAVING...';
actionsCell.style.color = 'var(--accent-color)';
actionsCell.style.cursor = 'not-allowed';
actionsCell.onclick = null;
// Send single config update
await sendConfigUpdateCommand([configObj]);
// Update the original value on success
const input = actionsCell.parentElement.cells[1].querySelector('input');
if (input) {
input.dataset.originalValue = newValue;
// Hide SAVE text since value now matches original
actionsCell.style.color = 'transparent';
actionsCell.style.cursor = 'default';
actionsCell.onclick = null;
}
actionsCell.textContent = 'SAVED';
actionsCell.style.color = 'var(--accent-color)';
setTimeout(() => {
actionsCell.textContent = 'SAVE';
// Keep transparent if value matches original
if (input && input.value === input.dataset.originalValue) {
actionsCell.style.color = 'transparent';
}
});
}, 2000);
if (configObjects.length === 0) {
console.log('No configuration changes to save');
return;
}
console.log(`Sending config_update commands for ${configObjects.length} configuration objects...`);
// Send config_update commands one at a time to avoid large event size
for (const configObj of configObjects) {
await sendConfigUpdateCommand([configObj]);
}
console.log('Configuration update command sent successfully');
// Exit edit mode
exitEditMode();
log(`Successfully saved config: ${key} = ${newValue}`, 'INFO');
} catch (error) {
console.log('Configuration save failed: ' + error.message);
console.error('Save configuration error:', error);
log(`Failed to save individual config ${key}: ${error.message}`, 'ERROR');
actionsCell.textContent = 'SAVE';
actionsCell.style.color = 'var(--primary-color)';
actionsCell.style.cursor = 'pointer';
actionsCell.onclick = () => saveIndividualConfig(key, actionsCell.parentElement.cells[1].querySelector('input').value, originalValue, actionsCell);
}
}
// Send config update command using kind 23456 with Administrator API (inner events)
async function sendConfigUpdateCommand(configObjects) {
try {
@@ -1619,35 +1653,7 @@
});
copyConfigBtn.addEventListener('click', function (e) {
e.preventDefault();
e.stopPropagation();
if (currentConfig) {
navigator.clipboard.writeText(JSON.stringify(currentConfig, null, 2))
.then(() => console.log('Configuration copied to clipboard'))
.catch(err => console.log('Failed to copy: ' + err.message));
}
});
editConfigBtn.addEventListener('click', function (e) {
e.preventDefault();
e.stopPropagation();
enterEditMode();
});
saveConfigBtn.addEventListener('click', function (e) {
e.preventDefault();
e.stopPropagation();
saveConfiguration().catch(error => {
console.log('Save configuration failed: ' + error.message);
});
});
cancelEditBtn.addEventListener('click', function (e) {
e.preventDefault();
e.stopPropagation();
exitEditMode();
});
// Relay connection event handlers
connectRelayBtn.addEventListener('click', function (e) {
@@ -2116,10 +2122,17 @@
}
}
// Update existing logout and showMainInterface functions to handle auth rules
// Update existing logout and showMainInterface functions to handle auth rules and NIP-17 DMs
const originalLogout = logout;
logout = async function () {
hideAuthRulesSection();
// Clear DM inbox and outbox on logout
if (dmInbox) {
dmInbox.innerHTML = '<div class="log-entry">No messages received yet.</div>';
}
if (dmOutbox) {
dmOutbox.value = '';
}
await originalLogout();
};
@@ -2936,6 +2949,156 @@
}
}
// Send NIP-17 Direct Message to relay using NIP-59 layering
async function sendNIP17DM() {
if (!isLoggedIn || !userPubkey) {
log('Must be logged in to send DM', 'ERROR');
return;
}
if (!isRelayConnected || !relayPubkey) {
log('Must be connected to relay to send DM', 'ERROR');
return;
}
const message = dmOutbox.value.trim();
if (!message) {
log('Please enter a message to send', 'ERROR');
return;
}
// Capability checks
if (!window.nostr || !window.nostr.nip44 || !window.nostr.signEvent) {
log('NIP-17 DMs require a NIP-07 extension with NIP-44 support', 'ERROR');
alert('NIP-17 DMs require a NIP-07 extension with NIP-44 support. Please install and configure a compatible extension.');
return;
}
if (!window.NostrTools || !window.NostrTools.generateSecretKey || !window.NostrTools.getPublicKey || !window.NostrTools.finalizeEvent) {
log('NostrTools library not available for ephemeral key operations', 'ERROR');
alert('NostrTools library not available. Please ensure nostr.bundle.js is loaded.');
return;
}
try {
log(`Sending NIP-17 DM to relay: ${message.substring(0, 50)}...`, 'INFO');
// Step 1: Build unsigned rumor (kind 14)
const rumor = {
kind: 14,
pubkey: userPubkey,
created_at: Math.floor(Date.now() / 1000), // Canonical time for rumor
tags: [["p", relayPubkey]],
content: message
};
// NOTE: Rumor remains unsigned per NIP-59
log('Rumor built (unsigned), creating seal...', 'INFO');
// Step 2: Create seal (kind 13)
const seal = {
kind: 13,
pubkey: userPubkey,
created_at: randomNow(), // Randomized to past for metadata protection
tags: [], // Empty tags per NIP-59
content: await window.nostr.nip44.encrypt(relayPubkey, JSON.stringify(rumor))
};
// Sign seal with long-term key
const signedSeal = await window.nostr.signEvent(seal);
if (!signedSeal || !signedSeal.sig) {
throw new Error('Failed to sign seal event');
}
log('Seal created and signed, creating gift wrap...', 'INFO');
// Step 3: Create gift wrap (kind 1059) with ephemeral key
const ephemeralPriv = window.NostrTools.generateSecretKey();
const ephemeralPub = window.NostrTools.getPublicKey(ephemeralPriv);
const giftWrap = {
kind: 1059,
pubkey: ephemeralPub,
created_at: randomNow(), // Randomized to past for metadata protection
tags: [["p", relayPubkey]],
content: await window.NostrTools.nip44.encrypt(
JSON.stringify(signedSeal),
window.NostrTools.nip44.getConversationKey(ephemeralPriv, relayPubkey)
)
};
// Sign gift wrap with ephemeral key using finalizeEvent
const signedGiftWrap = window.NostrTools.finalizeEvent(giftWrap, ephemeralPriv);
if (!signedGiftWrap || !signedGiftWrap.sig) {
throw new Error('Failed to sign gift wrap event');
}
log('NIP-17 DM event created and signed with ephemeral key, publishing...', 'INFO');
// Publish via SimplePool
const url = relayConnectionUrl.value.trim();
const publishPromises = relayPool.publish([url], signedGiftWrap);
// Use Promise.allSettled to capture per-relay outcomes
const results = await Promise.allSettled(publishPromises);
// Log detailed publish results
let successCount = 0;
results.forEach((result, index) => {
if (result.status === 'fulfilled') {
successCount++;
log(`✅ NIP-17 DM published successfully to relay ${index}`, 'INFO');
} else {
log(`❌ NIP-17 DM failed on relay ${index}: ${result.reason?.message || result.reason}`, 'ERROR');
}
});
if (successCount === 0) {
const errorDetails = results.map((r, i) => `Relay ${i}: ${r.reason?.message || r.reason}`).join('; ');
throw new Error(`All relays rejected NIP-17 DM event. Details: ${errorDetails}`);
}
// Clear the outbox and show success
dmOutbox.value = '';
log('NIP-17 DM sent successfully', 'INFO');
// Add to inbox for display
addMessageToInbox('sent', message, new Date().toLocaleString());
} catch (error) {
log(`Failed to send NIP-17 DM: ${error.message}`, 'ERROR');
}
}
// Add message to inbox display
function addMessageToInbox(direction, message, timestamp) {
if (!dmInbox) return;
const messageDiv = document.createElement('div');
messageDiv.className = 'log-entry';
const directionColor = direction === 'sent' ? '#007bff' : '#28a745';
messageDiv.innerHTML = `
<span class="log-timestamp">${timestamp}</span>
<span style="color: ${directionColor}; font-weight: bold;">[${direction.toUpperCase()}]</span>
${message}
`;
// Remove the "No messages received yet" placeholder if it exists
const placeholder = dmInbox.querySelector('.log-entry');
if (placeholder && placeholder.textContent === 'No messages received yet.') {
dmInbox.innerHTML = '';
}
// Add new message at the top
dmInbox.insertBefore(messageDiv, dmInbox.firstChild);
// Limit to last 50 messages
while (dmInbox.children.length > 50) {
dmInbox.removeChild(dmInbox.lastChild);
}
}
// Helper function to get relay pubkey
function getRelayPubkey() {
// Use the dynamically fetched relay pubkey if available
@@ -3251,6 +3414,11 @@
if (refreshStatsBtn) {
refreshStatsBtn.addEventListener('click', sendStatsQuery);
}
// NIP-17 DM event handlers
if (sendDmBtn) {
sendDmBtn.addEventListener('click', sendNIP17DM);
}
});
// Initialize the app