Browse Source
- Added a new compose tab for users to create, sign, and publish Nostr events. - Introduced functions for JSON reformatting, event signing, and publishing with WebSocket authentication. - Implemented clipboard functionality to copy event JSON directly from the UI. - Enhanced UI with buttons for reformatting, signing, and publishing events, improving user experience. - Created a new websocket-auth.js module for handling WebSocket authentication with Nostr relays. - Bumped version to v0.14.5.main
3 changed files with 554 additions and 3 deletions
@ -0,0 +1,251 @@ |
|||||||
|
/** |
||||||
|
* WebSocket Authentication Module for Nostr Relays |
||||||
|
* Implements NIP-42 authentication with proper challenge handling |
||||||
|
*/ |
||||||
|
|
||||||
|
export class NostrWebSocketAuth { |
||||||
|
constructor(relayUrl, userSigner, userPubkey) { |
||||||
|
this.relayUrl = relayUrl; |
||||||
|
this.userSigner = userSigner; |
||||||
|
this.userPubkey = userPubkey; |
||||||
|
this.ws = null; |
||||||
|
this.challenge = null; |
||||||
|
this.isAuthenticated = false; |
||||||
|
this.authPromise = null; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Connect to relay and handle authentication |
||||||
|
*/ |
||||||
|
async connect() { |
||||||
|
return new Promise((resolve, reject) => { |
||||||
|
this.ws = new WebSocket(this.relayUrl); |
||||||
|
|
||||||
|
this.ws.onopen = () => { |
||||||
|
console.log('WebSocket connected to relay:', this.relayUrl); |
||||||
|
resolve(); |
||||||
|
}; |
||||||
|
|
||||||
|
this.ws.onmessage = async (message) => { |
||||||
|
try { |
||||||
|
const data = JSON.parse(message.data); |
||||||
|
await this.handleMessage(data); |
||||||
|
} catch (error) { |
||||||
|
console.error('Error parsing relay message:', error); |
||||||
|
} |
||||||
|
}; |
||||||
|
|
||||||
|
this.ws.onerror = (error) => { |
||||||
|
console.error('WebSocket error:', error); |
||||||
|
reject(new Error('Failed to connect to relay')); |
||||||
|
}; |
||||||
|
|
||||||
|
this.ws.onclose = () => { |
||||||
|
console.log('WebSocket connection closed'); |
||||||
|
this.isAuthenticated = false; |
||||||
|
this.challenge = null; |
||||||
|
}; |
||||||
|
|
||||||
|
// Timeout for connection
|
||||||
|
setTimeout(() => { |
||||||
|
if (this.ws.readyState !== WebSocket.OPEN) { |
||||||
|
reject(new Error('Connection timeout')); |
||||||
|
} |
||||||
|
}, 10000); |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Handle incoming messages from relay |
||||||
|
*/ |
||||||
|
async handleMessage(data) { |
||||||
|
const [messageType, ...params] = data; |
||||||
|
|
||||||
|
switch (messageType) { |
||||||
|
case 'AUTH': |
||||||
|
// Relay sent authentication challenge
|
||||||
|
this.challenge = params[0]; |
||||||
|
console.log('Received AUTH challenge:', this.challenge); |
||||||
|
await this.authenticate(); |
||||||
|
break; |
||||||
|
|
||||||
|
case 'OK': |
||||||
|
const [eventId, success, reason] = params; |
||||||
|
if (eventId && success) { |
||||||
|
console.log('Authentication successful for event:', eventId); |
||||||
|
this.isAuthenticated = true; |
||||||
|
if (this.authPromise) { |
||||||
|
this.authPromise.resolve(); |
||||||
|
this.authPromise = null; |
||||||
|
} |
||||||
|
} else if (eventId && !success) { |
||||||
|
console.error('Authentication failed:', reason); |
||||||
|
if (this.authPromise) { |
||||||
|
this.authPromise.reject(new Error(reason || 'Authentication failed')); |
||||||
|
this.authPromise = null; |
||||||
|
} |
||||||
|
} |
||||||
|
break; |
||||||
|
|
||||||
|
case 'NOTICE': |
||||||
|
console.log('Relay notice:', params[0]); |
||||||
|
break; |
||||||
|
|
||||||
|
default: |
||||||
|
console.log('Unhandled message type:', messageType, params); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Authenticate with the relay using NIP-42 |
||||||
|
*/ |
||||||
|
async authenticate() { |
||||||
|
if (!this.challenge) { |
||||||
|
throw new Error('No challenge received from relay'); |
||||||
|
} |
||||||
|
|
||||||
|
if (!this.userSigner) { |
||||||
|
throw new Error('No signer available for authentication'); |
||||||
|
} |
||||||
|
|
||||||
|
try { |
||||||
|
// Create NIP-42 authentication event
|
||||||
|
const authEvent = { |
||||||
|
kind: 22242, // ClientAuthentication kind
|
||||||
|
created_at: Math.floor(Date.now() / 1000), |
||||||
|
tags: [ |
||||||
|
['relay', this.relayUrl], |
||||||
|
['challenge', this.challenge] |
||||||
|
], |
||||||
|
content: '', |
||||||
|
pubkey: this.userPubkey |
||||||
|
}; |
||||||
|
|
||||||
|
// Sign the authentication event
|
||||||
|
const signedAuthEvent = await this.userSigner.signEvent(authEvent); |
||||||
|
|
||||||
|
// Send AUTH message to relay
|
||||||
|
const authMessage = ["AUTH", signedAuthEvent]; |
||||||
|
this.ws.send(JSON.stringify(authMessage)); |
||||||
|
|
||||||
|
console.log('Sent authentication event to relay'); |
||||||
|
|
||||||
|
// Wait for authentication response
|
||||||
|
return new Promise((resolve, reject) => { |
||||||
|
this.authPromise = { resolve, reject }; |
||||||
|
|
||||||
|
// Timeout for authentication
|
||||||
|
setTimeout(() => { |
||||||
|
if (this.authPromise) { |
||||||
|
this.authPromise.reject(new Error('Authentication timeout')); |
||||||
|
this.authPromise = null; |
||||||
|
} |
||||||
|
}, 10000); |
||||||
|
}); |
||||||
|
|
||||||
|
} catch (error) { |
||||||
|
console.error('Authentication error:', error); |
||||||
|
throw error; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Publish an event to the relay |
||||||
|
*/ |
||||||
|
async publishEvent(event) { |
||||||
|
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { |
||||||
|
throw new Error('WebSocket not connected'); |
||||||
|
} |
||||||
|
|
||||||
|
return new Promise((resolve, reject) => { |
||||||
|
// Send EVENT message
|
||||||
|
const eventMessage = ["EVENT", event]; |
||||||
|
this.ws.send(JSON.stringify(eventMessage)); |
||||||
|
|
||||||
|
// Set up message handler for this specific event
|
||||||
|
const originalOnMessage = this.ws.onmessage; |
||||||
|
const timeout = setTimeout(() => { |
||||||
|
this.ws.onmessage = originalOnMessage; |
||||||
|
reject(new Error('Publish timeout')); |
||||||
|
}, 15000); |
||||||
|
|
||||||
|
this.ws.onmessage = async (message) => { |
||||||
|
try { |
||||||
|
const data = JSON.parse(message.data); |
||||||
|
const [messageType, eventId, success, reason] = data; |
||||||
|
|
||||||
|
if (messageType === 'OK' && eventId === event.id) { |
||||||
|
clearTimeout(timeout); |
||||||
|
this.ws.onmessage = originalOnMessage; |
||||||
|
|
||||||
|
if (success) { |
||||||
|
console.log('Event published successfully:', eventId); |
||||||
|
resolve({ success: true, eventId, reason }); |
||||||
|
} else { |
||||||
|
console.error('Event publish failed:', reason); |
||||||
|
|
||||||
|
// Check if authentication is required
|
||||||
|
if (reason && reason.includes('auth-required')) { |
||||||
|
console.log('Authentication required, attempting to authenticate...'); |
||||||
|
try { |
||||||
|
await this.authenticate(); |
||||||
|
// Re-send the event after authentication
|
||||||
|
const retryMessage = ["EVENT", event]; |
||||||
|
this.ws.send(JSON.stringify(retryMessage)); |
||||||
|
// Don't resolve yet, wait for the retry response
|
||||||
|
return; |
||||||
|
} catch (authError) { |
||||||
|
reject(new Error(`Authentication failed: ${authError.message}`)); |
||||||
|
return; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
reject(new Error(`Publish failed: ${reason}`)); |
||||||
|
} |
||||||
|
} else { |
||||||
|
// Handle other messages normally
|
||||||
|
await this.handleMessage(data); |
||||||
|
} |
||||||
|
} catch (error) { |
||||||
|
clearTimeout(timeout); |
||||||
|
this.ws.onmessage = originalOnMessage; |
||||||
|
reject(error); |
||||||
|
} |
||||||
|
}; |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Close the WebSocket connection |
||||||
|
*/ |
||||||
|
close() { |
||||||
|
if (this.ws) { |
||||||
|
this.ws.close(); |
||||||
|
this.ws = null; |
||||||
|
} |
||||||
|
this.isAuthenticated = false; |
||||||
|
this.challenge = null; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Check if currently authenticated |
||||||
|
*/ |
||||||
|
getAuthenticated() { |
||||||
|
return this.isAuthenticated; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Convenience function to publish an event with authentication |
||||||
|
*/ |
||||||
|
export async function publishEventWithAuth(relayUrl, event, userSigner, userPubkey) { |
||||||
|
const auth = new NostrWebSocketAuth(relayUrl, userSigner, userPubkey); |
||||||
|
|
||||||
|
try { |
||||||
|
await auth.connect(); |
||||||
|
const result = await auth.publishEvent(event); |
||||||
|
return result; |
||||||
|
} finally { |
||||||
|
auth.close(); |
||||||
|
} |
||||||
|
} |
||||||
Loading…
Reference in new issue