still not working

This commit is contained in:
n 2025-04-11 00:45:15 +01:00
parent 01ee4f9f51
commit 6da0b87fba
17 changed files with 2101 additions and 174 deletions

@ -37,10 +37,10 @@
</div>
<div id="billboardRelayStatus" class="relay-status">Not connected</div>
</div>
<div class="billboard-actions">
<button id="createBillboardBtn" class="primary-button">+ Add New Billboard</button>
</div>
</div>
<div class="billboard-container">
<div id="billboardContent" class="billboard-content">

@ -2,28 +2,55 @@
* auth-manager.ts
* Centralized authentication manager for the entire application.
* Prevents network requests until the user is properly authenticated.
*
* Updated to use the new AuthenticationService for more robust authentication.
*/
// Global authentication state
let userAuthenticated = false;
import { AuthenticationService } from './services/AuthenticationService';
// Queue for operations that should run after authentication
const postAuthQueue: Array<() => void> = [];
// Create singleton instance of AuthenticationService
const authService = new AuthenticationService();
// For backward compatibility
let userAuthenticated = false;
// Event name for authentication state changes
const AUTH_STATE_CHANGED_EVENT = 'auth-state-changed';
// Direct listeners registry for auth state changes
type AuthStateListener = (authenticated: boolean, pubkey?: string) => void;
const authStateListeners: AuthStateListener[] = [];
/**
* Notify all direct listeners of auth state change
*
* @param authenticated Whether the user is authenticated
* @param pubkey The user's public key (if authenticated)
*/
function notifyAuthStateListeners(authenticated: boolean, pubkey?: string): void {
for (const listener of authStateListeners) {
try {
listener(authenticated, pubkey);
} catch (error) {
console.error('Error in auth state change listener:', error);
}
}
}
// Queue for operations that should run after authentication
const postAuthQueue: Array<() => void> = [];
/**
* Check if the user is currently authenticated
*/
export function isAuthenticated(): boolean {
// Check localStorage first in case auth state was set in another page
const savedPubkey = localStorage.getItem('userPublicKey');
if (savedPubkey && !userAuthenticated) {
userAuthenticated = true;
}
// Use the AuthenticationService
const result = authService.isAuthenticated();
return userAuthenticated;
// Update the backward-compatible flag
userAuthenticated = result;
return result;
}
/**
@ -33,11 +60,20 @@ export function setAuthenticated(authenticated: boolean, pubkey?: string): void
const previousState = userAuthenticated;
userAuthenticated = authenticated;
// Update localStorage if a pubkey is provided
if (authenticated && pubkey) {
localStorage.setItem('userPublicKey', pubkey);
// For now, just store the pubkey in the AuthenticationService without full authentication
// This maintains backward compatibility while allowing us to use the newer service
const currentSession = authService.getCurrentSession();
// If there's no active session, we need to create one
if (!currentSession) {
// This will trigger a full authentication flow when the user next performs an action
// requiring signature verification
localStorage.setItem('userPublicKey', pubkey);
}
} else if (!authenticated) {
localStorage.removeItem('userPublicKey');
// Log out through the AuthenticationService
authService.logout();
}
// Execute queued operations if becoming authenticated
@ -45,14 +81,8 @@ export function setAuthenticated(authenticated: boolean, pubkey?: string): void
executePostAuthQueue();
}
// Dispatch an event so other parts of the app can react
if (typeof window !== 'undefined') {
window.dispatchEvent(
new CustomEvent(AUTH_STATE_CHANGED_EVENT, {
detail: { authenticated, pubkey }
})
);
}
// Notify listeners of state change
notifyAuthStateListeners(authenticated, pubkey);
}
/**
@ -91,8 +121,13 @@ function executePostAuthQueue(): void {
/**
* Listen for authentication state changes
* Supports both direct callback registration and CustomEvent listeners
*/
export function onAuthStateChanged(callback: (authenticated: boolean, pubkey?: string) => void): () => void {
export function onAuthStateChanged(callback: AuthStateListener): () => void {
// Add to direct listeners
authStateListeners.push(callback);
// Set up CustomEvent listener for backward compatibility
const handler = (event: Event) => {
const authEvent = event as CustomEvent;
callback(
@ -101,11 +136,22 @@ export function onAuthStateChanged(callback: (authenticated: boolean, pubkey?: s
);
};
window.addEventListener(AUTH_STATE_CHANGED_EVENT, handler);
if (typeof window !== 'undefined') {
window.addEventListener(AUTH_STATE_CHANGED_EVENT, handler);
}
// Return a function to remove the listener
// Return a function to remove both listeners
return () => {
window.removeEventListener(AUTH_STATE_CHANGED_EVENT, handler);
// Remove from direct listeners
const index = authStateListeners.indexOf(callback);
if (index > -1) {
authStateListeners.splice(index, 1);
}
// Remove CustomEvent listener
if (typeof window !== 'undefined') {
window.removeEventListener(AUTH_STATE_CHANGED_EVENT, handler);
}
};
}
@ -113,12 +159,15 @@ export function onAuthStateChanged(callback: (authenticated: boolean, pubkey?: s
* Clear authentication state (sign out)
*/
export function clearAuthState(): void {
userAuthenticated = false;
localStorage.removeItem('userPublicKey');
sessionStorage.removeItem('nostrLoginInitialized');
// Use the AuthenticationService to log out
authService.logout();
// Clear any temporary auth state in sessionStorage
// Update the backward-compatible flag
userAuthenticated = false;
// Clear temporary auth state in sessionStorage
[
'nostrLoginInitialized',
'nostrAuthInProgress',
'nostrLoginState',
'nostrAuthPending',
@ -126,15 +175,6 @@ export function clearAuthState(): void {
].forEach(key => {
sessionStorage.removeItem(key);
});
// Notify listeners
if (typeof window !== 'undefined') {
window.dispatchEvent(
new CustomEvent(AUTH_STATE_CHANGED_EVENT, {
detail: { authenticated: false }
})
);
}
}
/**
@ -142,18 +182,18 @@ export function clearAuthState(): void {
* This should be called on page load
*/
export function updateAuthStateFromStorage(): void {
const savedPubkey = localStorage.getItem('userPublicKey');
if (savedPubkey) {
userAuthenticated = true;
// Notify listeners
if (typeof window !== 'undefined') {
window.dispatchEvent(
new CustomEvent(AUTH_STATE_CHANGED_EVENT, {
detail: { authenticated: true, pubkey: savedPubkey }
})
);
// Let the AuthenticationService handle loading from storage
// It will check its own storage first, then fall back to the old localStorage key
// If we're not authenticated in the new service but have a key in the old format,
// use that to restore state
if (!authService.isAuthenticated()) {
const savedPubkey = localStorage.getItem('userPublicKey');
if (savedPubkey) {
userAuthenticated = true;
}
} else {
userAuthenticated = true;
}
}
@ -161,6 +201,13 @@ export function updateAuthStateFromStorage(): void {
// This ensures the auth state is set correctly when the module is loaded
updateAuthStateFromStorage();
// Initialize authentication when the page loads to ensure consistency
if (typeof window !== 'undefined') {
window.addEventListener('DOMContentLoaded', () => {
updateAuthStateFromStorage();
});
}
// Listen for page unload to cleanup authentication state
if (typeof window !== 'undefined') {
window.addEventListener('beforeunload', () => {
@ -169,12 +216,75 @@ if (typeof window !== 'undefined') {
// Clear any temporary auth state in sessionStorage
[
'nostrAuthInProgress',
'nostrLoginState',
'nostrAuthPending',
'nostrAuthInProgress',
'nostrLoginState',
'nostrAuthPending',
'nostrLoginStarted'
].forEach(key => {
sessionStorage.removeItem(key);
});
});
}
}
// Listen for auth state changes from the AuthenticationService
authService.onAuthStateChanged((authenticated, pubkey) => {
userAuthenticated = authenticated;
// Dispatch the legacy event for backward compatibility
if (typeof window !== 'undefined') {
window.dispatchEvent(
new CustomEvent(AUTH_STATE_CHANGED_EVENT, {
detail: { authenticated, pubkey }
})
);
}
// If becoming authenticated, execute queued operations
if (authenticated) {
executePostAuthQueue();
}
});
/**
* Get the current user's public key
*/
export function getCurrentUserPubkey(): string | null {
return authService.getCurrentUserPubkey();
}
/**
* Create an authenticated HTTP request using NIP-98
*
* @param method HTTP method
* @param url Request URL
* @param payload Optional request payload
* @returns Headers object with Authorization header, or null if not authenticated
*/
export async function createAuthHeaders(
method: string,
url: string,
payload?: string
): Promise<Headers | null> {
if (!isAuthenticated()) {
return null;
}
// Use the AuthenticationService to create a NIP-98 auth event
const authEvent = await authService.createAuthEvent(
method as any,
url,
payload
);
if (!authEvent) {
return null;
}
// Create headers from the auth event
return authService.createAuthHeaders(authEvent);
}
/**
* Export the AuthenticationService for direct use
*/
export { authService };

@ -20,19 +20,52 @@ let currentRelayUrl: string = '';
document.addEventListener('DOMContentLoaded', () => {
console.log('Initializing BILLBOARD page...');
// Make sure authentication state is fresh from localStorage
authManager.updateAuthStateFromStorage();
// Initialize services
// Create NostrService with a status update callback
nostrService = new NostrService((message: string, className: string) => {
updateRelayStatus(message, className);
});
// Initialize UI elements
setupUIElements();
// Update UI state based on authentication
updateUIBasedOnAuth();
// Auto-connect to the default relay after a brief delay
setTimeout(autoConnectToDefaultRelay, 500);
setTimeout(autoConnectToDefaultRelay, 500);
});
/**
* Update UI elements based on authentication state
*/
function updateUIBasedOnAuth(): void {
const createBillboardBtn = document.getElementById('createBillboardBtn');
if (createBillboardBtn) {
// First refresh auth state from storage to ensure it's current
authManager.updateAuthStateFromStorage();
// Enable or disable the create button based on auth state
if (authManager.isAuthenticated()) {
createBillboardBtn.removeAttribute('disabled');
createBillboardBtn.title = "Create a new billboard";
createBillboardBtn.classList.remove('disabled-button');
createBillboardBtn.classList.add('primary-button');
} else {
// Don't actually disable the button because we want to show the login prompt
// when clicked, but style it differently to indicate auth is required
createBillboardBtn.removeAttribute('disabled');
createBillboardBtn.title = "Please log in to create a billboard";
createBillboardBtn.classList.add('disabled-button');
createBillboardBtn.classList.remove('primary-button');
}
}
}
/**
* Set up UI elements and event listeners
*/
@ -56,7 +89,54 @@ function setupUIElements(): void {
// Create billboard button
const createBillboardBtn = document.getElementById('createBillboardBtn');
if (createBillboardBtn) {
createBillboardBtn.addEventListener('click', handleCreateBillboard);
createBillboardBtn.addEventListener('click', async () => {
// First refresh auth state from storage
authManager.updateAuthStateFromStorage();
// Check if user is authenticated
if (!authManager.isAuthenticated()) {
if (window.nostr) {
// If nostr extension is available, try to authenticate using our improved service
try {
const result = await authManager.authService.authenticate();
if (result.success && result.session) {
console.log(`Successfully authenticated with pubkey: ${result.session.pubkey.substring(0, 8)}...`);
handleCreateBillboard();
return;
}
} catch (error) {
console.error("Error during authentication:", error);
}
// Fall back to basic authentication if needed
try {
const pubkey = await window.nostr.getPublicKey();
if (pubkey) {
// Set authentication and proceed
authManager.setAuthenticated(true, pubkey);
console.log(`Direct login successful, pubkey: ${pubkey.substring(0, 8)}...`);
handleCreateBillboard();
return;
}
} catch (error) {
console.error("Error getting pubkey from extension:", error);
}
}
// If we get here, authentication failed - show a more helpful message
const promptResult = confirm(
'You need to be logged in to create a billboard.\nWould you like to go to the Profile page to log in now?'
);
if (promptResult) {
// Redirect to profile page
window.location.href = 'profile.html';
}
return;
}
handleCreateBillboard();
});
}
// Modal close button
@ -234,13 +314,14 @@ async function subscribeToKind31120Events(showAllEvents: boolean = false): Promi
};
// If not showing all events, filter by the current user's pubkey
let userPubkeyHex: string | null = null;
if (!showAllEvents) {
// Get the logged-in user's pubkey
const userPubkey = nostrService.getLoggedInPubkey();
if (userPubkey) {
// Convert npub to hex if needed
let userPubkeyHex = userPubkey;
userPubkeyHex = userPubkey;
if (userPubkey.startsWith('npub')) {
try {
const decoded = nostrTools.nip19.decode(userPubkey);
@ -259,6 +340,7 @@ async function subscribeToKind31120Events(showAllEvents: boolean = false): Promi
console.warn("No user pubkey available, showing all events despite filter setting");
}
}
// Update status message based on filter
const statusMessage = showAllEvents
? 'Subscribing to all server advertisements...'
@ -267,8 +349,12 @@ async function subscribeToKind31120Events(showAllEvents: boolean = false): Promi
updateRelayStatus(statusMessage, 'connecting');
try {
// Subscribe to events
// First, load events from cache and display them
await loadEventsFromCache(userPubkeyHex, showAllEvents);
// Subscribe to events for new incoming events
await nostrService.subscribeToEvents(filter);
// Set event handler
nostrService.setEventHandler((event) => {
if (event.kind === 31120 && event.id) {
@ -287,11 +373,71 @@ async function subscribeToKind31120Events(showAllEvents: boolean = false): Promi
}
}
/**
* Load events from cache and display them
* @param userPubkeyHex The user's pubkey in hex format (for filtering)
* @param showAllEvents Whether to show all events or only user's events
*/
async function loadEventsFromCache(userPubkeyHex: string | null, showAllEvents: boolean): Promise<void> {
try {
const relayUrl = nostrService.getRelayService().getActiveRelayUrl();
if (!relayUrl) {
console.warn('No active relay URL, cannot load cached events');
return;
}
// Get the cached events from the NostrCacheService via the NostrService
const cachedEvents = nostrService.getCacheService().getCachedEvents(relayUrl);
if (!cachedEvents || cachedEvents.length === 0) {
console.log('No cached events found');
return;
}
console.log(`Found ${cachedEvents.length} cached events, filtering for kind 31120`);
// Filter for kind 31120 events
let events = cachedEvents.filter(event => event.kind === 31120);
// Further filter by author if showAllEvents is false
if (!showAllEvents && userPubkeyHex) {
events = events.filter(event => event.pubkey === userPubkeyHex);
console.log(`Filtered to ${events.length} events by author: ${userPubkeyHex.substring(0, 8)}...`);
}
// Sort events by created_at (newest first)
events.sort((a, b) => b.created_at - a.created_at);
// Process each event to add it to the UI
console.log(`Displaying ${events.length} cached events`);
// Clear the billboard content if we're about to add events
if (events.length > 0) {
const billboardContent = document.getElementById('billboardContent');
if (billboardContent && billboardContent.querySelector('.empty-state')) {
billboardContent.innerHTML = '';
}
}
// Process each event
for (const event of events) {
processServerEvent(event as nostrTools.Event);
}
} catch (error) {
console.error('Error loading events from cache:', error);
}
}
/**
* Process a server advertisement event (kind 31120)
* Open the modal for creating a new billboard
*/
function handleCreateBillboard(): void {
// Check if user is logged in
if (!authManager.isAuthenticated()) {
alert('You need to be logged in to create a billboard. Please visit the Profile page to log in.');
return;
}
// Reset form
resetBillboardForm();
@ -683,6 +829,19 @@ function processServerEvent(event: nostrTools.Event): void {
}
});
}
/**
* Create a helper function to check if a specific element is in the viewport
*/
function isElementInViewport(el: Element): boolean {
const rect = el.getBoundingClientRect();
return (
rect.top >= 0 &&
rect.left >= 0 &&
rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) &&
rect.right <= (window.innerWidth || document.documentElement.clientWidth)
);
}
}
/**
@ -703,27 +862,144 @@ async function autoConnectToDefaultRelay(): Promise<void> {
showAllServerEventsCheckbox.checked = false;
}
// Update auth status from storage
authManager.updateAuthStateFromStorage();
// Try to authenticate directly with extension if possible
if (!authManager.isAuthenticated() && window.nostr) {
try {
console.log('Attempting direct authentication with Nostr extension...');
const pubkey = await window.nostr.getPublicKey();
if (pubkey) {
// Set the authenticated state
authManager.setAuthenticated(true, pubkey);
localStorage.setItem('userPublicKey', pubkey);
console.log(`Direct authentication successful, pubkey: ${pubkey.substring(0, 8)}...`);
// Update UI to reflect authentication status
updateUIBasedOnAuth();
}
} catch (error) {
console.error('Error authenticating with extension:', error);
}
}
// Always attempt to load cached events, even if not authenticated
try {
console.log('Attempting to load cached events...');
const relayUrl = relayUrlInput.value.trim();
// Initialize NostrService with the relay URL
try {
await nostrService.getRelayService().connectToRelay(relayUrl);
} catch (error) {
// If connecting fails due to auth, we'll still try to access cached events
console.warn('Could not connect to relay, but will try to load cached events:', error);
}
// Get showAllEvents checkbox state
let showAllEvents = false;
if (showAllServerEventsCheckbox) {
showAllEvents = showAllServerEventsCheckbox.checked;
}
// Get user pubkey for filtering
let userPubkeyHex: string | null = null;
if (!showAllEvents) {
const userPubkey = nostrService.getLoggedInPubkey();
if (userPubkey) {
// Convert npub to hex if needed
userPubkeyHex = userPubkey;
if (userPubkey.startsWith('npub')) {
try {
const decoded = nostrTools.nip19.decode(userPubkey);
if (decoded.type === 'npub') {
userPubkeyHex = decoded.data as string;
}
} catch (error) {
console.error("Error decoding npub:", error);
}
}
}
}
// Try to load events from cache
await loadEventsFromCache(userPubkeyHex, showAllEvents);
} catch (error) {
console.error('Error loading cached events:', error);
}
// Check if the user is authenticated before connecting
if (authManager.isAuthenticated()) {
console.log('User is authenticated, connecting to relay...');
// Trigger connect button click
// Trigger connect button click to establish active subscription
const connectButton = document.getElementById('billboardConnectBtn');
if (connectButton) {
connectButton.click();
}
} else {
console.log('User is not authenticated, showing login prompt...');
// Show a login prompt instead of connecting
// Show a login prompt with direct login option
const billboardContent = document.getElementById('billboardContent');
if (billboardContent) {
// Only show the login prompt if no events were loaded from cache
if (billboardContent && billboardContent.querySelector('.empty-state')) {
billboardContent.innerHTML = `
<div class="auth-required-message">
<h3>Authentication Required</h3>
<p>You need to be logged in to view and manage billboards.</p>
<p>Please visit the <a href="profile.html">Profile page</a> to log in with your Nostr extension.</p>
<p>
<button id="directLoginBtn" class="primary-button">Login with Nostr Extension</button>
or visit the <a href="profile.html">Profile page</a> to login.
</p>
</div>
`;
// Add event listener to the direct login button
const directLoginBtn = document.getElementById('directLoginBtn');
if (directLoginBtn) {
directLoginBtn.addEventListener('click', async () => {
if (window.nostr) {
try {
directLoginBtn.textContent = 'Authenticating...';
directLoginBtn.setAttribute('disabled', 'true');
// Try to authenticate using our improved service
const result = await authManager.authService.authenticate();
if (result.success && result.session) {
console.log(`Direct login successful using authService, pubkey: ${result.session.pubkey.substring(0, 8)}...`);
// Update UI and reload
updateUIBasedOnAuth();
window.location.reload();
return;
}
// Fall back to basic pubkey retrieval
const pubkey = await window.nostr.getPublicKey();
if (pubkey) {
authManager.setAuthenticated(true, pubkey);
localStorage.setItem('userPublicKey', pubkey);
console.log(`Direct login successful, pubkey: ${pubkey.substring(0, 8)}...`);
// Update UI and connect
updateUIBasedOnAuth();
// Reload the page to properly initialize
window.location.reload();
}
} catch (error) {
console.error('Error during direct login:', error);
directLoginBtn.textContent = 'Login Failed - Try Again';
directLoginBtn.removeAttribute('disabled');
}
} else {
alert('No Nostr extension detected. Please install NIP-07 compatible extension like nos2x or Alby.');
}
});
}
}
// Update the relay status
updateRelayStatus('Authentication required', 'warning');
}

@ -58,23 +58,73 @@ function setupResponseSubscription(): void {
return;
}
// Create filter for KIND 21121 responses
const filter: { kinds: number[] } = { kinds: [21121] };
// Get all stored events to include their IDs in the filter
const storedEvents = clientEventStore.getAllEvents();
// Extract event IDs to use for filtering relevant 21121 responses
const eventIds = storedEvents.map(event => event.id);
// Create an enhanced filter for KIND 21121 responses
// We'll create two separate subscriptions:
// 1. One that matches specific events we know about
// 2. One that catches any other 21121 events (to make sure we don't miss any)
// The first filter includes the '#e' tag to match responses to our specific requests
const specificFilter: { kinds: number[], '#e': string[] } = {
kinds: [21121],
'#e': []
};
// The second filter catches all other 21121 events
const generalFilter = {
kinds: [21121]
};
// If we have stored events, add their IDs to the specific filter
if (eventIds.length > 0) {
specificFilter['#e'] = eventIds;
console.log(`Setting up specific subscription with ${eventIds.length} stored event IDs`);
}
// Get the WebSocket manager to set up the subscription
const wsManager = relayService.getWebSocketManager();
// Set up the websocket subscription
// Set up the websocket subscription with more detailed logging
wsManager.connect(activeRelayUrl, {
timeout: 5000,
timeout: 10000, // Increased timeout for more reliable connection
onOpen: (ws) => {
// Send a REQ message to subscribe
const reqId = `client-events-21121-sub-${Date.now()}`;
const reqMsg = JSON.stringify(["REQ", reqId, filter]);
ws.send(reqMsg);
console.log(`Subscribed to KIND 21121 responses on ${activeRelayUrl}`);
console.log(`WebSocket connection opened to ${activeRelayUrl} for 21121 subscriptions`);
// First send subscription for specific events we know about
if (eventIds.length > 0) {
const specificReqId = `client-events-21121-specific-${Date.now()}`;
const specificReqMsg = JSON.stringify(["REQ", specificReqId, specificFilter]);
ws.send(specificReqMsg);
console.log('===== SENT SPECIFIC SUBSCRIPTION REQUEST =====');
console.log('REQ ID:', specificReqId);
console.log('Filter:', JSON.stringify(specificFilter, null, 2));
console.log('==============================================');
console.log(`Subscribed to KIND 21121 responses for ${eventIds.length} specific events on ${activeRelayUrl}`);
}
// Then send subscription for all other 21121 events
const generalReqId = `client-events-21121-general-${Date.now()}`;
const generalReqMsg = JSON.stringify(["REQ", generalReqId, generalFilter]);
ws.send(generalReqMsg);
console.log('===== SENT GENERAL SUBSCRIPTION REQUEST =====');
console.log('REQ ID:', generalReqId);
console.log('Filter:', JSON.stringify(generalFilter, null, 2));
console.log('=============================================');
console.log(`Subscribed to all KIND 21121 events on ${activeRelayUrl}`);
},
onMessage: (data) => {
// Log all incoming messages for debugging
console.log('===== RECEIVED RELAY MESSAGE =====');
console.log('Message data:', JSON.stringify(data, null, 2));
console.log('==================================');
// Parse the incoming message
try {
// Type assertion for the received data
@ -84,10 +134,46 @@ function setupResponseSubscription(): void {
if (Array.isArray(nostrData) && nostrData[0] === "EVENT" && nostrData.length >= 3) {
const receivedEvent = nostrData[2] as NostrEvent;
console.log('Received event kind:', receivedEvent.kind);
// Process only KIND 21121 events
if (receivedEvent && receivedEvent.kind === 21121) {
handleIncomingResponse(receivedEvent);
console.log('Processing 21121 response event:', receivedEvent.id);
console.log('Tags:', JSON.stringify(receivedEvent.tags, null, 2));
// Get the original request ID from the e tag
const requestTag = receivedEvent.tags.find(tag => tag[0] === 'e');
const requestId = requestTag && requestTag.length > 1 ? requestTag[1] : null;
if (requestId) {
console.log(`21121 response is for request: ${requestId}`);
// Get the stored event to check if it exists
const storedEvent = clientEventStore.getEvent(requestId);
if (!storedEvent) {
console.warn(`No matching stored event found for request ID ${requestId}`);
// Print all stored event IDs for debugging
const allEvents = clientEventStore.getAllEvents();
console.log(`Currently tracking ${allEvents.length} events. IDs:`,
allEvents.map(e => e.id).join(', '));
}
} else {
console.warn(`21121 response is missing request ID in e tag`);
}
const result = handleIncomingResponse(receivedEvent);
if (result) {
console.log(`Successfully processed 21121 response for request: ${requestId || 'unknown'}`);
} else {
console.warn(`Failed to process 21121 response for request: ${requestId || 'unknown'}`);
}
} else {
console.log(`Ignoring non-21121 event (kind: ${receivedEvent.kind})`);
}
} else if (Array.isArray(nostrData) && nostrData[0] === "EOSE") {
console.log(`Received EOSE for subscription: ${nostrData[1]}`);
} else {
console.log(`Received unhandled message type: ${nostrData[0]}`);
}
} catch (error) {
console.error('Error processing 21121 message:', error);
@ -98,6 +184,15 @@ function setupResponseSubscription(): void {
},
onClose: () => {
console.log('21121 subscription connection closed');
// Attempt to reconnect after a short delay with exponential backoff
const backoffDelay = Math.floor(Math.random() * 5000) + 5000; // 5-10 seconds
console.log(`Will attempt to reconnect in ${backoffDelay/1000} seconds...`);
setTimeout(() => {
console.log('Attempting to resubscribe to 21121 events...');
setupResponseSubscription();
}, backoffDelay);
}
}).catch(error => {
console.error('Failed to connect for 21121 subscription:', error);
@ -122,7 +217,9 @@ export function trackOutgoingEvent(event: NostrEvent): string | null {
}
// Add to the store
return clientEventStore.addOutgoingEvent(event);
const eventId = clientEventStore.addOutgoingEvent(event);
console.log(`Tracked outgoing event: ${eventId}`);
return eventId;
}
/**
@ -168,20 +265,77 @@ export function handleIncomingResponse(event: NostrEvent): boolean {
const eTag = event.tags.find(tag => tag[0] === 'e');
if (!eTag || eTag.length < 2) {
console.warn('Response event missing valid e tag with request ID');
console.log('All tags:', JSON.stringify(event.tags, null, 2));
return false;
}
console.log(`Found e tag in 21121 response: ${eTag[1]}`);
const requestId = eTag[1];
// Check if we have this request in our store
const storedEvent = clientEventStore.getEvent(requestId);
if (!storedEvent) {
// This response doesn't match any of our tracked requests
console.warn(`Received response for unknown request ID: ${requestId}`);
const allEvents = clientEventStore.getAllEvents();
console.log(`We have ${allEvents.length} tracked events. IDs:`, allEvents.map(e => e.id).join(', '));
// Try to find if there's a similar request ID (in case of case sensitivity issues)
for (const storedEvent of allEvents) {
if (storedEvent.id.toLowerCase() === requestId.toLowerCase()) {
console.log(`Found case-insensitive match: ${storedEvent.id}`);
// Construct a proper Nostr event with the correctly cased event ID
if (event.pubkey && event.created_at && event.kind && event.content) {
const fixedEvent: NostrEvent = {
...event,
tags: [['e', storedEvent.id]]
};
return handleIncomingResponse(fixedEvent);
}
}
}
return false;
}
console.log(`Matching request found for response: ${requestId}`);
// Check if we already have a response for this request
if (storedEvent.responseId) {
console.log(`We already have response ${storedEvent.responseId} for request ${requestId}`);
// If this is the same response ID, don't add it again
if (storedEvent.responseId === event.id) {
console.log(`Same response ID received again, not re-adding`);
return true; // Return true since we already have this stored
}
console.log(`Received new response ${event.id}, replacing existing one`);
}
// Check response validity
if (!event.content) {
console.warn(`Response has empty content, adding anyway`);
}
// Add the response to the store, linked to the original request
return clientEventStore.addResponseEvent(event);
const result = clientEventStore.addResponseEvent(event);
console.log(`Response ${event.id} added to store for request ${requestId}: ${result ? 'SUCCESS' : 'FAILED'}`);
// After adding to store, check if it's actually there
const updatedEvent = clientEventStore.getEvent(requestId);
if (updatedEvent && updatedEvent.responseId === event.id) {
console.log(`Verified: response ${event.id} is now correctly linked to request ${requestId}`);
// Additional check for the full event object
if (updatedEvent.responseEvent) {
console.log(`Response event object is present in the store`);
} else {
console.error(`Response ID is set but full event object is missing!`);
}
} else {
console.error(`Failed to verify stored response for request ${requestId}. Current state:`, updatedEvent);
}
return result;
}
/**
@ -202,6 +356,7 @@ export function disposeClientEventHandler(): void {
if (clientEventsTable) {
clientEventsTable.dispose();
clientEventsTable = null;
console.log('Disposed client events table component');
}
// Close WebSocket connections if needed
@ -213,5 +368,28 @@ export function disposeClientEventHandler(): void {
console.error('Error closing WebSocket connection:', e);
}
relayService = null;
console.log('Closed WebSocket connections and reset relay service');
}
}
/**
* Force a reconnection to the relay service
* This can be useful if the user manually wants to refresh connections
*/
export function reconnectRelayService(): void {
if (relayService) {
try {
// Close existing connections first
const wsManager = relayService.getWebSocketManager();
wsManager.close();
// Then set up a new subscription
console.log('Manually reconnecting relay service...');
setupResponseSubscription();
} catch (e) {
console.error('Error during manual reconnection:', e);
}
} else {
console.warn('Cannot reconnect: No relay service available');
}
}

@ -44,7 +44,8 @@ import { displayConvertedEvent } from './converter';
import {
initClientEventHandler,
trackOutgoingEvent,
handleIncomingResponse
handleIncomingResponse,
reconnectRelayService
} from './client-event-handler';
import { publishToRelay, convertNpubToHex, verifyEvent } from './relay';
// Import profile functions (not using direct imports since we'll load modules based on page)
@ -785,11 +786,17 @@ async function handlePublishEvent(): Promise<void> {
try {
// Track the event before publishing
console.log('Tracking outgoing event:', event.id);
trackOutgoingEvent(event);
// Publish the event
console.log('Publishing event to relay:', relayUrl);
const result = await publishToRelay(event, relayUrl);
showSuccess(publishResultDiv, result);
// Force reconnect to ensure we're subscribed for the response
console.log('Reconnecting to listen for response...');
reconnectRelayService();
} catch (publishError) {
showError(publishResultDiv, String(publishError));
// Don't rethrow, just handle it here so the UI shows the error
@ -863,11 +870,17 @@ async function handlePublishEvent(): Promise<void> {
try {
// Track the event in our client store
console.log('Tracking outgoing event:', event.id);
trackOutgoingEvent(event);
// Publish the event
console.log('Publishing event to relay:', relayUrl);
const result = await publishToRelay(event, relayUrl);
showSuccess(publishResultDiv, result);
// Force reconnect to ensure we're subscribed for the response
console.log('Reconnecting to listen for response...');
reconnectRelayService();
} catch (publishError) {
showError(publishResultDiv, String(publishError));
// Don't rethrow, just handle it here so the UI shows the error
@ -1126,26 +1139,71 @@ function handleToggleKeyFormat(): void {
async function safeAuthenticate(): Promise<string | null> {
console.log("Starting safe authentication...");
// Clear any stale auth state
window.sessionStorage.removeItem('nostrLoginInitialized');
['nostrAuthInProgress', 'nostrLoginState', 'nostrAuthPending', 'nostrLoginStarted'].forEach(key => {
window.sessionStorage.removeItem(key);
});
// Check if auth is already in progress to prevent duplicate attempts
if (window.authInProgress) {
console.log("Authentication already in progress, waiting...");
return new Promise((resolve) => {
// Set up a small interval to check when the auth is complete
const checkInterval = setInterval(() => {
if (!window.authInProgress) {
clearInterval(checkInterval);
// After auth is complete, check if we have a pubkey
const pubkey = localStorage.getItem('userPublicKey');
resolve(pubkey);
}
}, 100);
// Set a timeout in case the auth gets stuck
setTimeout(() => {
clearInterval(checkInterval);
console.log("Authentication wait timeout, proceeding...");
const pubkey = localStorage.getItem('userPublicKey');
resolve(pubkey);
}, 5000);
});
}
// Initialize NostrLogin
initNostrLogin();
// Set the global auth flag to prevent duplicate attempts
window.authInProgress = true;
// Wait a moment for initialization to complete
await new Promise(resolve => setTimeout(resolve, 300));
// If we already have a pubkey in localStorage, use that
const savedPubkey = localStorage.getItem('userPublicKey');
if (savedPubkey) {
console.log(`Found saved pubkey: ${savedPubkey.substring(0, 8)}...`);
updateClientPubkeyDisplay(savedPubkey);
// Set authentication state in the central auth manager
authManager.setAuthenticated(true, savedPubkey);
return savedPubkey;
try {
// Clear any stale auth state
window.sessionStorage.removeItem('nostrLoginInitialized');
['nostrAuthInProgress', 'nostrLoginState', 'nostrAuthPending', 'nostrLoginStarted'].forEach(key => {
window.sessionStorage.removeItem(key);
});
// Initialize NostrLogin
initNostrLogin();
// Wait a moment for initialization to complete
await new Promise(resolve => setTimeout(resolve, 300));
// Check if we're already authenticated via AuthenticationService
if (authManager.authService.isAuthenticated()) {
console.log("Already authenticated via AuthenticationService");
window.authInProgress = false;
const pubkey = authManager.getCurrentUserPubkey();
if (pubkey) {
updateClientPubkeyDisplay(pubkey);
return pubkey;
}
}
// If we already have a pubkey in localStorage, use that
const savedPubkey = localStorage.getItem('userPublicKey');
if (savedPubkey) {
console.log(`Found saved pubkey: ${savedPubkey.substring(0, 8)}...`);
updateClientPubkeyDisplay(savedPubkey);
// Set authentication state in the central auth manager
authManager.setAuthenticated(true, savedPubkey);
window.authInProgress = false;
return savedPubkey;
}
} catch (error) {
console.error("Error in authentication setup:", error);
window.authInProgress = false;
return null;
}
// Set a flag that we're starting auth
@ -1156,26 +1214,46 @@ async function safeAuthenticate(): Promise<string | null> {
if (window.nostr) {
console.log("Requesting public key from extension...");
// First try using our enhanced AuthenticationService
try {
const result = await authManager.authService.authenticate();
if (result.success && result.session) {
const pubkey = result.session.pubkey;
console.log(`Authentication successful via AuthenticationService, pubkey: ${pubkey.substring(0, 8)}...`);
localStorage.setItem('userPublicKey', pubkey);
updateClientPubkeyDisplay(pubkey);
window.authInProgress = false;
return pubkey;
}
} catch (authServiceError) {
console.warn("AuthenticationService authentication failed, falling back to extension:", authServiceError);
}
// Fall back to direct extension request
const pubkey = await window.nostr.getPublicKey();
if (pubkey) {
console.log(`Authentication successful, pubkey: ${pubkey.substring(0, 8)}...`);
console.log(`Authentication successful via extension, pubkey: ${pubkey.substring(0, 8)}...`);
localStorage.setItem('userPublicKey', pubkey);
updateClientPubkeyDisplay(pubkey);
// Set authentication state in the central auth manager
authManager.setAuthenticated(true, pubkey);
window.authInProgress = false;
return pubkey;
}
} else {
console.warn("Nostr extension not available");
}
window.authInProgress = false;
return null;
} catch (error) {
console.error("Error during authentication:", error);
window.authInProgress = false;
return null;
} finally {
// Clear the in-progress flag
window.sessionStorage.removeItem('nostrAuthInProgress');
window.authInProgress = false;
}
}
@ -1213,6 +1291,30 @@ window.addEventListener('beforeunload', () => {
async function retryAuthentication(): Promise<void> {
console.log("Manual authentication retry requested");
// Check if auth is already in progress
if (window.authInProgress) {
console.log("Authentication already in progress, please wait...");
// If auth is in progress, just update UI
const statusElement = document.getElementById('authStatus');
if (statusElement) {
statusElement.textContent = 'Authentication already in progress...';
statusElement.className = 'status-message loading';
}
// Set a timeout to check if auth completed
setTimeout(() => {
if (authManager.isAuthenticated()) {
if (statusElement) {
statusElement.textContent = 'Authentication successful!';
statusElement.className = 'status-message success';
}
}
}, 5000);
return;
}
// Clear UI status
const statusElement = document.getElementById('authStatus');
if (statusElement) {
@ -1220,7 +1322,8 @@ async function retryAuthentication(): Promise<void> {
statusElement.className = 'status-message loading';
}
// Clear all auth state
// Force reset auth state to clear any stuck processes
window.authInProgress = false;
window.sessionStorage.removeItem('nostrLoginInitialized');
['nostrAuthInProgress', 'nostrLoginState', 'nostrAuthPending', 'nostrLoginStarted'].forEach(key => {
window.sessionStorage.removeItem(key);
@ -1229,7 +1332,25 @@ async function retryAuthentication(): Promise<void> {
// Wait a moment for state to clear
await new Promise(resolve => setTimeout(resolve, 300));
// Try authentication again
// Try authentication again with our enhanced service first
try {
const result = await authManager.authService.authenticate();
if (result.success && result.session) {
console.log(`Authentication successful via AuthenticationService: ${result.session.pubkey.substring(0, 8)}...`);
// Update UI status
if (statusElement) {
statusElement.textContent = 'Authentication successful!';
statusElement.className = 'status-message success';
}
return;
}
} catch (error) {
console.warn("Could not authenticate with AuthenticationService, falling back:", error);
}
// Fall back to standard authentication
const pubkey = await safeAuthenticate();
// Update status

@ -10,6 +10,7 @@ import type { ClientEventStore, ClientStoredEvent } from '../services/ClientEven
import { ClientEventStatus } from '../services/ClientEventStore';
import { HttpFormatter } from '../services/HttpFormatter';
import { nip19 } from 'nostr-tools';
import { reconnectRelayService } from '../client-event-handler';
export class ClientEventsTable {
private container: HTMLElement | null = null;
@ -55,11 +56,130 @@ export class ClientEventsTable {
// Render existing events
this.renderExistingEvents();
// Display notification if events were loaded from localStorage
const events = this.eventStore.getAllEvents();
if (events.length > 0) {
this.showPersistenceNotification(events.length);
}
}
/**
* Shows a notification that events were loaded from localStorage
* @param count Number of events loaded
*/
private showPersistenceNotification(count: number): void {
// Create notification element
const notification = document.createElement('div');
notification.className = 'event-persistence-notification';
notification.innerHTML = `
<p>${count} events were loaded from storage. Your events are now persisted between page reloads.</p>
<button class="dismiss-btn"></button>
`;
// Style the notification
const style = notification.style;
style.position = 'fixed';
style.bottom = '20px';
style.right = '20px';
style.backgroundColor = '#2c7c3e';
style.color = 'white';
style.padding = '10px 15px';
style.borderRadius = '4px';
style.boxShadow = '0 2px 5px rgba(0,0,0,0.2)';
style.zIndex = '1000';
style.maxWidth = '320px';
// Add dismiss button functionality
const dismissBtn = notification.querySelector('.dismiss-btn');
if (dismissBtn) {
dismissBtn.addEventListener('click', () => {
document.body.removeChild(notification);
});
// Style the dismiss button
const btnStyle = (dismissBtn as HTMLElement).style;
btnStyle.background = 'none';
btnStyle.border = 'none';
btnStyle.color = 'white';
btnStyle.float = 'right';
btnStyle.cursor = 'pointer';
btnStyle.fontSize = '16px';
}
// Add to document and set timeout to auto-remove
document.body.appendChild(notification);
setTimeout(() => {
if (document.body.contains(notification)) {
document.body.removeChild(notification);
}
}, 5000);
}
private createTableStructure(): void {
if (!this.container) { return; }
// Clear any existing content
this.container.innerHTML = '';
// Create table controls including reconnect button
const controlsDiv = document.createElement('div');
controlsDiv.className = 'client-events-controls';
controlsDiv.innerHTML = `
<button id="reconnectButton" class="reconnect-button">Reconnect to Relay</button>
<span id="reconnectStatus" class="reconnect-status"></span>
`;
// Style the controls
const controlsStyle = controlsDiv.style;
controlsStyle.marginBottom = '10px';
controlsStyle.display = 'flex';
controlsStyle.alignItems = 'center';
controlsStyle.gap = '10px';
// Add reconnect button functionality
const reconnectButton = controlsDiv.querySelector('#reconnectButton');
if (reconnectButton) {
reconnectButton.addEventListener('click', () => {
const statusSpan = document.getElementById('reconnectStatus');
if (statusSpan) {
statusSpan.textContent = 'Reconnecting...';
statusSpan.style.color = '#ff9900';
}
// Call the reconnection function
reconnectRelayService();
// Update status after a moment
setTimeout(() => {
if (statusSpan) {
statusSpan.textContent = 'Reconnection attempt sent';
statusSpan.style.color = '#33cc33';
// Clear the status after a few seconds
setTimeout(() => {
if (statusSpan) {
statusSpan.textContent = '';
}
}, 3000);
}
}, 1000);
});
// Style the button
const buttonStyle = (reconnectButton as HTMLElement).style;
buttonStyle.padding = '6px 12px';
buttonStyle.backgroundColor = '#2c7c3e';
buttonStyle.color = 'white';
buttonStyle.border = 'none';
buttonStyle.borderRadius = '4px';
buttonStyle.cursor = 'pointer';
}
// Add the controls to the container
this.container.appendChild(controlsDiv);
// Create the table
const tableHtml = `
<table class="client-events-table">
<thead>
@ -78,10 +198,12 @@ export class ClientEventsTable {
</table>
`;
this.container.innerHTML = tableHtml;
const tableDiv = document.createElement('div');
tableDiv.innerHTML = tableHtml;
this.container.appendChild(tableDiv);
this.tableBody = document.getElementById('clientEventsTableBody');
}
private renderExistingEvents(): void {
if (!this.tableBody) { return; }
@ -91,8 +213,15 @@ export class ClientEventsTable {
// Get all events from the store
const events = this.eventStore.getAllEvents();
console.log(`ClientEventsTable: Rendering ${events.length} existing events`);
// Count how many have responses
const eventsWithResponses = events.filter(e => e.responseId !== undefined).length;
console.log(`ClientEventsTable: ${eventsWithResponses} events have responses`);
// If no events, show the empty state
if (events.length === 0) {
console.log(`ClientEventsTable: No events to render, showing empty state`);
this.tableBody.innerHTML = `
<tr class="table-empty-state">
<td colspan="4">No outgoing HTTP requests yet. Use the form above to send requests.</td>
@ -264,7 +393,14 @@ export class ClientEventsTable {
// Check if we have a response
const responseEvent = storedEvent.responseEvent;
const hasResponseId = !!storedEvent.responseId;
// Debug log for response status
console.log(`ClientEventsTable: Event ${eventId} response status - responseId: ${storedEvent.responseId || 'none'}, responseEvent: ${responseEvent ? 'present' : 'missing'}`);
if (responseEvent) {
console.log(`ClientEventsTable: Showing 21121 response (kind: ${responseEvent.kind}) for event ${eventId}`);
// Populate 21121 JSON response tab
jsonResponseTab.innerHTML = `<pre>${JSON.stringify(responseEvent, null, 2)}</pre>`;
@ -276,8 +412,16 @@ export class ClientEventsTable {
}
} else {
// No response yet
jsonResponseTab.innerHTML = '<div class="empty-response">No response received yet</div>';
httpResponseTab.innerHTML = '<div class="empty-response">No response received yet</div>';
console.log(`ClientEventsTable: No 21121 response available for event ${eventId}`);
if (hasResponseId) {
console.warn(`ClientEventsTable: Event has responseId (${storedEvent.responseId}) but no responseEvent object`);
jsonResponseTab.innerHTML = '<div class="empty-response warning">Response ID exists but response data is missing. Try reconnecting to relay.</div>';
httpResponseTab.innerHTML = '<div class="empty-response warning">Response ID exists but response data is missing. Try reconnecting to relay.</div>';
} else {
jsonResponseTab.innerHTML = '<div class="empty-response">No response received yet</div>';
httpResponseTab.innerHTML = '<div class="empty-response">No response received yet</div>';
}
}
}
@ -292,6 +436,11 @@ export class ClientEventsTable {
if (this.modal && this.modal.parentNode) {
this.modal.parentNode.removeChild(this.modal);
}
// Remove reconnect button event listener if it exists
const reconnectButton = document.getElementById('reconnectButton');
if (reconnectButton) {
reconnectButton.removeEventListener('click', () => {});
}
// Clear references
this.container = null;

@ -19,29 +19,37 @@ export interface NostrEvent {
sig?: string;
}
// Type definition for the window.nostrTools object
// Define interfaces to avoid unused parameter errors
/* eslint-disable no-unused-vars */
interface NostrWindowExtension {
getPublicKey: () => Promise<string>;
signEvent: (event: NostrEvent) => Promise<NostrEvent>;
nip44?: {
encrypt(plaintext: string, pubkey: string): Promise<string>;
decrypt(ciphertext: string, pubkey: string): Promise<string>;
};
}
// Import the WindowNostrExtension type from window.d.ts
import type { WindowNostrExtension } from './types/window';
declare global {
interface Window {
nostrTools: Record<string, unknown>;
nostr: NostrWindowExtension;
nostrTools: NostrTools;
nostr?: WindowNostrExtension;
currentSignedEvent?: NostrEvent;
}
}
// Import nostr-login for encryption/signing
// eslint-disable-next-line no-undef
const NostrLogin = typeof require !== 'undefined' ? require('nostr-login') : null;
// Define better types for the NostrLogin module
interface NostrLoginModule {
signEvent: (event: NostrEvent) => Promise<NostrEvent>;
sign: (event: NostrEvent) => Promise<NostrEvent>;
}
const NostrLogin: NostrLoginModule | null = typeof require !== 'undefined' ? require('nostr-login') : null;
// Define proper types for nostr-tools
interface NostrTools {
generateSecretKey: () => Uint8Array;
getPublicKey: (secretKey: Uint8Array) => string;
finalizeEvent: (event: NostrEvent, secretKey: Uint8Array) => NostrEvent;
nip19: {
decode: (encoded: string) => { type: string; data: string | Record<string, unknown> };
npubEncode: (hex: string) => string;
};
}
// Generate a keypair for standalone mode (when no extension is available)
let standaloneSecretKey: Uint8Array | null = null;
@ -54,8 +62,13 @@ function initStandaloneKeypair(): { publicKey: string, secretKey: Uint8Array } {
standalonePublicKey = nostrTools.getPublicKey(standaloneSecretKey);
}
// Ensure the values exist before returning
if (!standalonePublicKey || !standaloneSecretKey) {
throw new Error('Failed to initialize standalone keypair');
}
return {
publicKey: standalonePublicKey!,
publicKey: standalonePublicKey,
secretKey: standaloneSecretKey
};
}
@ -90,7 +103,7 @@ export async function convertToEvent(
// Create a single kind 21120 event with encrypted HTTP request as content
// Using Web Crypto API for proper encryption
let encryptedContent: string;
let encryptedContent = '';
try {
// Convert server pubkey to hex if it's an npub
@ -137,8 +150,12 @@ export async function convertToEvent(
console.log(`Converting p tag npub to hex: ${serverPubkey}`);
const decoded = nostrTools.nip19.decode(serverPubkey);
if (decoded.type === 'npub' && decoded.data) {
pTagValue = decoded.data as string;
console.log(`Converted to hex pubkey: ${pTagValue}`);
if (typeof decoded.data === 'string') {
pTagValue = decoded.data;
console.log(`Converted to hex pubkey: ${pTagValue}`);
} else {
throw new Error("Decoded npub data is not a string");
}
} else {
throw new Error("Failed to decode npub properly");
}
@ -320,7 +337,8 @@ export async function displayConvertedEvent(): Promise<void> {
if (convertedEvent) {
// Store the original event in case we need to reference it
(window as any).originalEvent = convertedEvent;
// Use proper type instead of 'any'
(window as { originalEvent?: string }).originalEvent = convertedEvent;
// Variable to hold the Nostr event
let nostrEvent: NostrEvent;
@ -404,7 +422,7 @@ export async function displayConvertedEvent(): Promise<void> {
return;
}
// Store the event in a global variable for easier access during publishing
(window as any).currentSignedEvent = signedEvent;
window.currentSignedEvent = signedEvent;
// Display the event JSON
eventOutputPre.textContent = JSON.stringify(signedEvent, null, 2);
@ -418,14 +436,35 @@ if (publishRelayInput) {
if (publishResult) {
publishResult.innerHTML = '<span class="loading">Publishing to relay...</span>';
// Publish the event using the publishToRelay function
publishToRelay(signedEvent, relayUrl)
.then((result: string) => {
showSuccess(publishResult, result);
})
.catch((error: Error) => {
publishResult.innerHTML = `<span class="error">Error publishing: ${error.message}</span>`;
});
// Import the tracking function from client-event-handler
import('./client-event-handler').then(module => {
// Track the event before publishing
try {
module.trackOutgoingEvent(signedEvent);
console.log('Event tracked successfully before publishing');
} catch (trackError) {
console.error('Error tracking event:', trackError);
}
// Publish the event using the publishToRelay function
publishToRelay(signedEvent, relayUrl)
.then((result: string) => {
showSuccess(publishResult, result);
})
.catch((error: Error) => {
publishResult.innerHTML = `<span class="error">Error publishing: ${error.message}</span>`;
});
}).catch(importError => {
console.error('Error importing client-event-handler:', importError);
// Fall back to just publishing without tracking
publishToRelay(signedEvent, relayUrl)
.then((result: string) => {
showSuccess(publishResult, result);
})
.catch((error: Error) => {
publishResult.innerHTML = `<span class="error">Error publishing: ${error.message}</span>`;
});
});
}
}
@ -434,13 +473,18 @@ if (publishRelayInput) {
const qrCodeContainer = document.getElementById('qrCode') as HTMLElement;
if (qrCodeContainer) {
try {
// Ensure signedEvent exists before processing
if (!signedEvent) {
throw new Error('No signed event available for QR code generation');
}
// Convert the event to a JSON string
const eventJson = JSON.stringify(signedEvent);
// Calculate how many QR codes we need based on the data size
// Use a much smaller chunk size to ensure it fits in the QR code
const maxChunkSize = 500; // Significantly reduced chunk size
const chunks = [];
const chunks: string[] = [];
// Split the data into chunks
for (let i = 0; i < eventJson.length; i += maxChunkSize) {
@ -454,7 +498,8 @@ if (publishRelayInput) {
qrCodeContainer.appendChild(qrFrameContainer);
// Create QR codes for each chunk with chunk number and total chunks
const qrFrames: HTMLElement[] = [];
// Type the frames array properly
const qrFrames: HTMLDivElement[] = [];
chunks.forEach((chunk, index) => {
// Add metadata to identify part of sequence: [current/total]:[data]
const dataWithMeta = `${index + 1}/${chunks.length}:${chunk}`;
@ -518,14 +563,19 @@ if (publishRelayInput) {
let animationInterval: number | null = null;
let isPaused = false;
const updateFrameInfo = () => {
const updateFrameInfo = (): void => {
const frameInfo = qrCodeContainer.querySelector('.current-frame');
if (frameInfo) {
if (frameInfo instanceof HTMLElement) {
frameInfo.textContent = `Showing frame ${currentFrame + 1} of ${chunks.length}`;
}
};
const showFrame = (index: number) => {
const showFrame = (index: number): void => {
if (index < 0 || index >= qrFrames.length) {
console.error(`Invalid frame index: ${index}, valid range: 0-${qrFrames.length - 1}`);
return;
}
qrFrames.forEach((frame, i) => {
frame.style.display = i === index ? 'block' : 'none';
});
@ -533,16 +583,21 @@ if (publishRelayInput) {
updateFrameInfo();
};
const nextFrame = () => {
showFrame((currentFrame + 1) % qrFrames.length);
const nextFrame = (): void => {
if (qrFrames.length > 0) {
showFrame((currentFrame + 1) % qrFrames.length);
}
};
const prevFrame = () => {
showFrame((currentFrame - 1 + qrFrames.length) % qrFrames.length);
const prevFrame = (): void => {
if (qrFrames.length > 0) {
showFrame((currentFrame - 1 + qrFrames.length) % qrFrames.length);
}
};
// Start the animation
animationInterval = window.setInterval(nextFrame, 2000); // Change frame every 2 seconds
// Use proper window.setInterval type
animationInterval = window.setInterval(() => nextFrame(), 2000); // Change frame every 2 seconds
// Set up button event handlers
const pauseBtn = document.getElementById('qrPauseBtn');
@ -550,9 +605,9 @@ if (publishRelayInput) {
const nextBtn = document.getElementById('qrNextBtn');
if (pauseBtn) {
pauseBtn.addEventListener('click', () => {
pauseBtn.addEventListener('click', (): void => {
if (isPaused) {
animationInterval = window.setInterval(nextFrame, 2000);
animationInterval = window.setInterval(() => nextFrame(), 2000);
pauseBtn.textContent = 'Pause';
} else {
if (animationInterval !== null) {
@ -566,7 +621,7 @@ if (publishRelayInput) {
}
if (prevBtn) {
prevBtn.addEventListener('click', () => {
prevBtn.addEventListener('click', (): void => {
if (isPaused) {
prevFrame();
}
@ -574,7 +629,7 @@ if (publishRelayInput) {
}
if (nextBtn) {
nextBtn.addEventListener('click', () => {
nextBtn.addEventListener('click', (): void => {
if (isPaused) {
nextFrame();
}
@ -588,7 +643,11 @@ if (publishRelayInput) {
// Create a backup QR code with just the event ID and relay
try {
const eventId = signedEvent.id || '';
if (!signedEvent || !signedEvent.id) {
throw new Error('No valid signed event available for backup QR code');
}
const eventId = signedEvent.id;
const relay = encodeURIComponent(defaultServerConfig.defaultRelay);
const nostrUri = `nostr:${eventId}?relay=${relay}`;
@ -605,7 +664,7 @@ if (publishRelayInput) {
<p class="qr-info">This QR code contains a reference to the event: ${eventId.substring(0, 8)}...</p>
</div>
`;
} catch {
} catch (error) {
qrCodeContainer.innerHTML = `
<div class="qr-error-container">
<h3>QR Generation Failed</h3>
@ -631,16 +690,19 @@ document.addEventListener('DOMContentLoaded', () => {
const publishButton = document.getElementById('publishButton');
if (convertButton) {
convertButton.addEventListener('click', displayConvertedEvent);
convertButton.addEventListener('click', (): void => {
void displayConvertedEvent();
});
}
// Add a handler for the publish button to check if an event is available
if (publishButton) {
publishButton.addEventListener('click', () => {
publishButton.addEventListener('click', (): void => {
const eventOutput = document.getElementById('eventOutput');
const publishResult = document.getElementById('publishResult');
if (!eventOutput || !publishResult) {
console.error('Unable to find eventOutput or publishResult elements');
return;
}

@ -1,6 +1,8 @@
// External dependencies
import * as nostrTools from 'nostr-tools';
import qrcode from 'qrcode-generator';
import * as authManager from './auth-manager';
import { SecureStorageService } from './services/SecureStorageService';
// Interface for profile data
interface ProfileData {
@ -36,24 +38,56 @@ let currentProfileData: ProfileData = {
};
// Connect to extension
async function connectWithExtension(): Promise<void> {
async function connectWithExtension(): Promise<string | null> {
try {
if (!window.nostr) {
updateConnectionStatus('No Nostr extension detected. Please install a NIP-07 compatible extension.', false);
return;
return null;
}
const pubkey = await window.nostr.getPublicKey();
if (pubkey) {
currentPubkey = pubkey;
updateConnectionStatus(`Connected with extension using pubkey: ${pubkey.substring(0, 8)}...`, true);
showProfile(pubkey);
// Use the AuthenticationService via auth-manager for a more secure login
const result = await authManager.authService.authenticate();
if (result.success && result.session) {
currentPubkey = result.session.pubkey;
// Log the successful authentication
console.log(`Authentication successful using secure method for pubkey: ${currentPubkey.substring(0, 8)}...`);
updateConnectionStatus(`Connected with extension using pubkey: ${currentPubkey.substring(0, 8)}...`, true);
showProfile(currentPubkey);
// Return the pubkey so it can be used by the caller
return currentPubkey;
} else {
updateConnectionStatus('Failed to get public key from extension', false);
// Fall back to the old method if needed
console.log('Secure authentication failed, trying legacy method');
const pubkey = await window.nostr.getPublicKey();
if (pubkey) {
currentPubkey = pubkey;
// Set the authenticated state using auth-manager
authManager.setAuthenticated(true, pubkey);
console.log(`Authentication successful (legacy) for pubkey: ${pubkey.substring(0, 8)}...`);
updateConnectionStatus(`Connected with extension using pubkey: ${pubkey.substring(0, 8)}...`, true);
showProfile(pubkey);
// Return the pubkey so it can be used by the caller
return pubkey;
} else {
authManager.setAuthenticated(false);
updateConnectionStatus('Failed to get public key from extension', false);
return null;
}
}
} catch (err) {
console.error('Error connecting with extension:', err);
// Make sure we set authentication to false on error
authManager.setAuthenticated(false);
updateConnectionStatus(`Error: ${err instanceof Error ? err.message : String(err)}`, false);
return null;
}
}
@ -322,7 +356,7 @@ async function refreshProfile(): Promise<void> {
}
// Initialize
document.addEventListener('DOMContentLoaded', () => {
document.addEventListener('DOMContentLoaded', async () => {
// Get DOM elements
connectionStatus = document.getElementById('connectionStatus');
profileContainer = document.getElementById('profileContainer');
@ -354,17 +388,47 @@ document.addEventListener('DOMContentLoaded', () => {
// Try to connect automatically
if (window.nostr) {
// If extension is available, try to connect with it
connectWithExtension().catch(error => {
try {
const pubkey = await connectWithExtension();
if (pubkey) {
// We already set the auth state in connectWithExtension, but let's log success
console.log(`Successfully authenticated with pubkey: ${pubkey.substring(0, 8)}...`);
// Store the session expiry in localStorage for UI purposes
const secureStorage = new SecureStorageService();
const session = authManager.authService.getCurrentSession();
if (session) {
secureStorage.set('session_info', {
expiresAt: session.expiresAt,
createdAt: session.createdAt
});
console.log(`Session expires at ${new Date(session.expiresAt).toLocaleString()}`);
}
// Make double sure the auth manager has the correct state
authManager.updateAuthStateFromStorage();
console.log('Auth state after connection:', authManager.isAuthenticated() ? 'Authenticated' : 'Not authenticated');
} else {
console.warn('Failed to get pubkey from extension');
authManager.setAuthenticated(false);
}
} catch (error) {
console.error('Error auto-connecting with extension:', error);
// Clear authentication state on error
authManager.setAuthenticated(false);
// Show profile container even if connection fails
if (profileContainer) {
profileContainer.classList.remove('hidden');
updateProfileWithDefaults();
}
});
}
} else {
// Even without an extension, show the profile container with default information
console.log('No Nostr extension available, showing default profile');
// Ensure authentication state is cleared
authManager.setAuthenticated(false);
if (profileContainer) {
profileContainer.classList.remove('hidden');
updateProfileWithDefaults();

@ -0,0 +1,461 @@
/**
* AuthenticationService.ts
*
* Provides authentication services using Nostr protocol.
* Implements NIP-98 (HTTP Auth) for authenticated requests.
*/
import * as nostrTools from 'nostr-tools';
import { SecureStorageService } from './SecureStorageService';
/**
* Interface for auth session data
*/
export interface AuthSession {
/** User's public key in hex format */
pubkey: string;
/** User's public key in bech32/npub format */
npub: string;
/** Signature proving ownership */
signature: string;
/** When the session was created (timestamp) */
createdAt: number;
/** When the session expires (timestamp) */
expiresAt: number;
/** Optional refresh token for extending the session */
refreshToken?: string;
}
/**
* Interface for authentication operation results
*/
export interface AuthResult {
/** Whether the operation was successful */
success: boolean;
/** The session if successful */
session?: AuthSession;
/** Error message if unsuccessful */
error?: string;
}
/**
* Interface for NIP-98 HTTP Auth event
*/
export interface Nip98AuthEvent extends nostrTools.Event {
/** Event tags including method, uri, etc. */
tags: string[][];
}
/**
* HTTP method types for NIP-98
*/
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'OPTIONS';
/**
* Main authentication service class
*/
export class AuthenticationService {
private secureStorage: SecureStorageService;
private currentSession: AuthSession | null = null;
// Storage keys
private readonly SESSION_KEY = 'authSession';
private readonly REFRESH_TOKEN_KEY = 'refreshToken';
// Default duration (24 hours) in milliseconds
private readonly SESSION_DURATION = 24 * 60 * 60 * 1000;
// Event name for authentication state changes
private readonly AUTH_STATE_CHANGED_EVENT = 'auth-state-changed';
/**
* Constructor
*/
constructor() {
this.secureStorage = new SecureStorageService();
this.loadSessionFromStorage();
this.setupEventHandlers();
}
/**
* Authenticate with Nostr extension and create a signed session
*
* @returns Promise resolving to authentication result
*/
public async authenticate(): Promise<AuthResult> {
try {
// Check if Nostr extension exists
if (!window.nostr) {
return {
success: false,
error: 'No Nostr extension detected. Please install a NIP-07 compatible extension.'
};
}
// Get public key from extension
const pubkey = await window.nostr.getPublicKey();
if (!pubkey) {
return {
success: false,
error: 'Failed to get public key from extension.'
};
}
// Generate a challenge the user must sign to prove key ownership
const challenge = `Authenticate for HTTP-over-Nostr at ${window.location.hostname} at ${Date.now()}`;
// Ask user to sign the challenge
let signature: string;
try {
// Use signEvent since signSchnorr may not be available in all extensions
const challengeEvent = {
kind: 27235, // NIP-98 HTTP Auth kind
created_at: Math.floor(Date.now() / 1000),
tags: [['challenge', challenge]],
content: '',
pubkey
};
const signedEvent = await window.nostr.signEvent(challengeEvent);
signature = signedEvent.sig || '';
} catch (error) {
return {
success: false,
error: 'Failed to sign authentication challenge. User may have rejected the request.'
};
}
// Assume signature is valid if the extension signed it
// The extension handles the verification internally
const isValid = !!signature;
if (!isValid) {
return {
success: false,
error: 'Signature verification failed.'
};
}
// Create session
const now = Date.now();
const session: AuthSession = {
pubkey,
npub: nostrTools.nip19.npubEncode(pubkey),
signature,
createdAt: now,
expiresAt: now + this.SESSION_DURATION,
refreshToken: this.generateRefreshToken()
};
// Store session
this.currentSession = session;
this.persistSession(session);
// Notify listeners
this.notifyAuthStateChanged(true, pubkey);
return {
success: true,
session
};
} catch (error) {
console.error('Authentication error:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown authentication error'
};
}
}
/**
* Check if the user is currently authenticated with a valid session
*
* @returns True if authenticated with a valid session
*/
public isAuthenticated(): boolean {
if (!this.currentSession) {
return false;
}
// Check if session is expired
if (this.currentSession.expiresAt < Date.now()) {
// Try to refresh the session if possible
if (this.currentSession.refreshToken) {
this.refreshSession().catch(error => {
console.error('Failed to refresh session:', error);
});
}
return false;
}
return true;
}
/**
* Get the current session if authenticated
*
* @returns The current session or null if not authenticated
*/
public getCurrentSession(): AuthSession | null {
if (this.isAuthenticated()) {
return this.currentSession;
}
return null;
}
/**
* Get the current user's public key
*
* @returns The user's public key or null if not authenticated
*/
public getCurrentUserPubkey(): string | null {
return this.currentSession?.pubkey || null;
}
/**
* Logout the current user
*/
public logout(): void {
const wasPreviouslyAuthenticated = this.isAuthenticated();
const previousPubkey = this.getCurrentUserPubkey();
this.currentSession = null;
this.secureStorage.remove(this.SESSION_KEY);
this.secureStorage.remove(this.REFRESH_TOKEN_KEY);
// Only notify if there was an actual state change
if (wasPreviouslyAuthenticated && previousPubkey) {
this.notifyAuthStateChanged(false, previousPubkey);
} else if (wasPreviouslyAuthenticated) {
this.notifyAuthStateChanged(false);
}
}
/**
* Create a signed NIP-98 HTTP Auth event
*
* @param method HTTP method
* @param url Request URL
* @param payload Optional request payload/body
* @returns Promise resolving to signed event or null if failed
*/
public async createAuthEvent(
method: HttpMethod,
url: string,
payload?: string
): Promise<Nip98AuthEvent | null> {
try {
// Ensure user is authenticated
if (!this.isAuthenticated() || !window.nostr) {
return null;
}
// Parse URL to get hostname and path
const parsedUrl = new URL(url);
// Create tags according to NIP-98
const tags: string[][] = [
['method', method],
['uri', url],
];
// Add content-type and digest for POST and PUT requests
if ((method === 'POST' || method === 'PUT') && payload) {
tags.push(['payload', payload]);
tags.push(['content-type', 'application/json']);
// Add SHA-256 digest of the payload
const digest = await this.sha256Digest(payload);
tags.push(['digest', `SHA-256=${digest}`]);
}
// Add created timestamp
const created = Math.floor(Date.now() / 1000);
tags.push(['created', created.toString()]);
// Create the event
const event = {
kind: 27235, // NIP-98 HTTP Auth
created_at: created,
tags,
content: '',
pubkey: this.currentSession!.pubkey
};
// Sign the event with NIP-07 extension
const signedEvent = await window.nostr.signEvent(event);
return signedEvent as Nip98AuthEvent;
} catch (error) {
console.error('Error creating NIP-98 auth event:', error);
return null;
}
}
/**
* Create HTTP headers for NIP-98 authentication
*
* @param event Signed NIP-98 auth event
* @returns Headers object with Authorization header
*/
public createAuthHeaders(event: Nip98AuthEvent): Headers {
const headers = new Headers();
const authValue = `Nostr ${JSON.stringify(event)}`;
headers.append('Authorization', authValue);
return headers;
}
/**
* Extend the current session's expiration
*
* @param extendBy Milliseconds to extend the session by (defaults to SESSION_DURATION)
* @returns True if session was extended
*/
public extendSession(extendBy?: number): boolean {
if (!this.currentSession) {
return false;
}
const extension = extendBy || this.SESSION_DURATION;
this.currentSession.expiresAt = Date.now() + extension;
this.persistSession(this.currentSession);
return true;
}
/**
* Listen for authentication state changes
*
* @param callback Function to call when auth state changes
* @returns Function to remove the listener
*/
public onAuthStateChanged(
callback: (authenticated: boolean, pubkey?: string) => void
): () => void {
const handler = (event: Event) => {
const authEvent = event as CustomEvent;
callback(
authEvent.detail.authenticated,
authEvent.detail.pubkey
);
};
window.addEventListener(this.AUTH_STATE_CHANGED_EVENT, handler);
// Return function to remove the listener
return () => {
window.removeEventListener(this.AUTH_STATE_CHANGED_EVENT, handler);
};
}
/**
* Attempt to refresh the current session
*
* @returns Promise resolving to true if session was refreshed
*/
private async refreshSession(): Promise<boolean> {
// This would typically extend the session without requiring re-authentication
// For now, we'll just extend the current session
if (!this.currentSession || !this.currentSession.refreshToken) {
return false;
}
const storedRefreshToken = this.secureStorage.get<string>(this.REFRESH_TOKEN_KEY);
if (!storedRefreshToken || storedRefreshToken !== this.currentSession.refreshToken) {
return false;
}
// Extend the session
return this.extendSession();
}
/**
* Generate a random refresh token
*
* @returns A random string to use as refresh token
*/
private generateRefreshToken(): string {
return Math.random().toString(36).substring(2, 15) +
Math.random().toString(36).substring(2, 15);
}
/**
* Persist session to secure storage
*
* @param session The session to persist
*/
private persistSession(session: AuthSession): void {
this.secureStorage.set(
this.SESSION_KEY,
session,
{ expiresIn: session.expiresAt - Date.now() }
);
if (session.refreshToken) {
this.secureStorage.set(
this.REFRESH_TOKEN_KEY,
session.refreshToken,
{ expiresIn: (session.expiresAt - Date.now()) + (7 * 24 * 60 * 60 * 1000) } // Refresh token valid for 1 week longer
);
}
}
/**
* Load session from secure storage
*/
private loadSessionFromStorage(): void {
const session = this.secureStorage.get<AuthSession>(this.SESSION_KEY);
if (session) {
this.currentSession = session;
// Notify if a valid session is loaded
if (this.isAuthenticated()) {
this.notifyAuthStateChanged(true, session.pubkey);
}
}
}
/**
* Set up event handlers
*/
private setupEventHandlers(): void {
// Clean up expired items on page load
this.secureStorage.cleanExpired();
// Setup beforeunload event to save session
window.addEventListener('beforeunload', () => {
if (this.currentSession) {
this.persistSession(this.currentSession);
}
});
}
/**
* Notify listeners of authentication state changes
*
* @param authenticated Whether the user is authenticated
* @param pubkey The user's pubkey (if authenticated)
*/
private notifyAuthStateChanged(authenticated: boolean, pubkey?: string): void {
window.dispatchEvent(
new CustomEvent(this.AUTH_STATE_CHANGED_EVENT, {
detail: { authenticated, pubkey }
})
);
}
/**
* Calculate SHA-256
*
* @param message Message to hash
* @returns Base64-encoded hash
*/
private async sha256Digest(message: string): Promise<string> {
const encoder = new TextEncoder();
const data = encoder.encode(message);
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
const hashArray = Array.from(new Uint8Array(hashBuffer));
const base64Hash = btoa(String.fromCharCode(...hashArray));
return base64Hash;
}
}

@ -52,6 +52,16 @@ export class ClientEventStore {
// Event change listeners
private listeners: ClientEventChangeListener[] = [];
// Storage key for localStorage
private readonly STORAGE_KEY = 'nostr_client_events';
/**
* Constructor initializes the store and loads any saved events
*/
constructor() {
this.loadFromStorage();
}
/**
* Add an outgoing KIND 21120 event to the store
* @param event The Nostr event to add
@ -76,6 +86,10 @@ export class ClientEventStore {
this.outgoingEvents.set(event.id, storedEvent);
this.notifyListeners(event.id, EventChangeType.Added);
// Save to localStorage after adding
this.saveToStorage();
return event.id;
}
@ -85,6 +99,8 @@ export class ClientEventStore {
* @returns True if successfully added, false otherwise
*/
public addResponseEvent(responseEvent: NostrEvent): boolean {
console.log(`ClientEventStore: Adding response event ${responseEvent.id}`);
if (!responseEvent.id) {
console.error('Response event must have an ID');
return false;
@ -101,17 +117,38 @@ export class ClientEventStore {
return false;
}
console.log(`ClientEventStore: Response ${responseEvent.id} is for request ${requestId}`);
// Check if we have the outgoing event
const outgoingEvent = this.outgoingEvents.get(requestId);
if (!outgoingEvent) {
console.warn(`Response received for unknown request: ${requestId}`);
console.log('Stored request IDs:', Array.from(this.outgoingEvents.keys()).join(', '));
return false;
}
// Check if we already have a response for this request
if (outgoingEvent.responseId) {
console.log(`ClientEventStore: Request ${requestId} already has response ${outgoingEvent.responseId}`);
// If we're getting a duplicate response, log but still update
if (outgoingEvent.responseId === responseEvent.id) {
console.log(`ClientEventStore: Duplicate response received, not updating`);
return true; // Consider this successful since we already have it
} else {
console.log(`ClientEventStore: New response received, replacing old one`);
}
}
console.log(`ClientEventStore: Updating request ${requestId} with response ${responseEvent.id}`);
// Make a deep copy of the response event to ensure it doesn't get modified
const responseEventCopy = JSON.parse(JSON.stringify(responseEvent));
// Update the outgoing event with response data
outgoingEvent.status = ClientEventStatus.Responded;
outgoingEvent.responseId = responseEvent.id;
outgoingEvent.responseEvent = responseEvent;
outgoingEvent.responseEvent = responseEventCopy;
outgoingEvent.responseReceivedAt = Date.now();
// Update the outgoing events map
@ -120,9 +157,32 @@ export class ClientEventStore {
// Update the response map
this.responseMap.set(responseEvent.id, requestId);
// Debug log
console.log(`ClientEventStore: Updated maps - outgoing events: ${this.outgoingEvents.size}, response map: ${this.responseMap.size}`);
// Log the updated event to verify the data
console.log(`ClientEventStore: Updated event:`, {
id: outgoingEvent.id,
status: outgoingEvent.status,
responseId: outgoingEvent.responseId,
responseReceivedAt: outgoingEvent.responseReceivedAt,
responseEventPresent: !!outgoingEvent.responseEvent
});
// Extra verification that the response was stored properly
const verifyEvent = this.outgoingEvents.get(requestId);
if (verifyEvent && verifyEvent.responseEvent) {
console.log(`ClientEventStore: Verification - response event is properly stored`);
} else {
console.error(`ClientEventStore: Verification FAILED - response event not stored properly`);
}
// Notify listeners
this.notifyListeners(requestId, EventChangeType.Updated);
// Save to localStorage after updating
this.saveToStorage();
return true;
}
@ -141,6 +201,10 @@ export class ClientEventStore {
storedEvent.status = status;
this.outgoingEvents.set(eventId, storedEvent);
this.notifyListeners(eventId, EventChangeType.Updated);
// Save to localStorage after updating
this.saveToStorage();
return true;
}
@ -212,6 +276,9 @@ export class ClientEventStore {
// Notify listeners
this.notifyListeners(eventId, EventChangeType.Removed);
// Save to localStorage after removing
this.saveToStorage();
return true;
}
@ -229,6 +296,9 @@ export class ClientEventStore {
for (const id of eventIds) {
this.notifyListeners(id, EventChangeType.Removed);
}
// Clear localStorage
localStorage.removeItem(this.STORAGE_KEY);
}
/**
@ -273,4 +343,83 @@ export class ClientEventStore {
}
}
}
/**
* Save the current state to localStorage
*/
private saveToStorage(): void {
try {
// Convert Map objects to serializable format
const storageData = {
outgoingEvents: Array.from(this.outgoingEvents.entries()),
responseMap: Array.from(this.responseMap.entries())
};
// Count events with responses for debugging
const eventsWithResponses = Array.from(this.outgoingEvents.values())
.filter(e => e.responseId !== undefined).length;
console.log(`ClientEventStore: Saving to localStorage - ${this.outgoingEvents.size} events (${eventsWithResponses} with responses)`);
// Stringify and save
const jsonData = JSON.stringify(storageData);
localStorage.setItem(this.STORAGE_KEY, jsonData);
console.log(`ClientEventStore: Data saved to localStorage (${jsonData.length} bytes)`);
} catch (error) {
console.error('Error saving client events to localStorage:', error);
}
}
/**
* Load stored events from localStorage
*/
private loadFromStorage(): void {
try {
console.log(`ClientEventStore: Loading events from localStorage`);
const storedData = localStorage.getItem(this.STORAGE_KEY);
if (!storedData) {
console.log(`ClientEventStore: No stored data found`);
return; // No stored data
}
console.log(`ClientEventStore: Found stored data (${storedData.length} bytes)`);
const parsedData = JSON.parse(storedData);
// Restore outgoing events
if (parsedData.outgoingEvents && Array.isArray(parsedData.outgoingEvents)) {
this.outgoingEvents = new Map(parsedData.outgoingEvents);
console.log(`ClientEventStore: Restored ${this.outgoingEvents.size} outgoing events`);
// Debug: Check how many events have responses
const eventsWithResponses = Array.from(this.outgoingEvents.values())
.filter(e => e.responseId !== undefined).length;
console.log(`ClientEventStore: ${eventsWithResponses} events have responses`);
// Log some sample events for debugging
if (this.outgoingEvents.size > 0) {
const sampleEvents = Array.from(this.outgoingEvents.values()).slice(0, 3);
console.log(`ClientEventStore: Sample restored events:`,
sampleEvents.map(e => ({
id: e.id,
status: e.status,
hasResponse: e.responseId !== undefined,
responseId: e.responseId
}))
);
}
}
// Restore response map
if (parsedData.responseMap && Array.isArray(parsedData.responseMap)) {
this.responseMap = new Map(parsedData.responseMap);
console.log(`ClientEventStore: Restored ${this.responseMap.size} response mappings`);
}
console.log(`ClientEventStore: Successfully loaded data from localStorage`);
} catch (error) {
console.error('Error loading client events from localStorage:', error);
}
}
}

@ -9,7 +9,7 @@ import { getServerPubkeyFromEvent } from './NostrUtils';
export interface CacheEntry {
timestamp: number;
events: {[key: string]: NostrEvent};
events: {[key: string]: NostrEvent[]}; // Change to store an array of events per server pubkey
}
export class EventCache {
@ -33,20 +33,36 @@ export class EventCache {
* @param events Events to cache
*/
public cacheEvents(key: string, events: NostrEvent[]): void {
// Create a map of events by server pubkey
const eventsMap: {[key: string]: NostrEvent} = {};
// Get existing cache entry or create a new one
const existingEntry = this.memoryCache.get(key);
const currentEventsMap = existingEntry ? existingEntry.events : {};
// Group events by server pubkey
const eventsByPubkey: {[key: string]: NostrEvent[]} = {...currentEventsMap};
for (const event of events) {
const serverPubkey = getServerPubkeyFromEvent(event);
if (serverPubkey) {
eventsMap[serverPubkey] = event;
// Add the event to the array for this server pubkey
if (!eventsByPubkey[serverPubkey]) {
eventsByPubkey[serverPubkey] = [];
}
// Check if event already exists (by ID) and remove it to avoid duplicates
const existingIndex = eventsByPubkey[serverPubkey].findIndex(e => e.id === event.id);
if (existingIndex !== -1) {
eventsByPubkey[serverPubkey].splice(existingIndex, 1);
}
// Add the new event to the array
eventsByPubkey[serverPubkey].push(event);
}
}
const timestamp = Date.now();
const cacheData: CacheEntry = {
timestamp,
events: eventsMap
events: eventsByPubkey
};
// Store in memory cache
@ -55,7 +71,7 @@ export class EventCache {
// Store in localStorage for persistence
try {
localStorage.setItem(this.cachePrefix + key, JSON.stringify(cacheData));
console.log(`Cached ${Object.keys(eventsMap).length} events for ${key}`);
console.log(`Cached events for ${key}. Server pubkeys: ${Object.keys(eventsByPubkey).length}`);
} catch (error) {
console.error('Failed to store cache in localStorage:', error);
// Keep the memory cache even if localStorage fails
@ -73,7 +89,8 @@ export class EventCache {
// Try memory cache first (fastest)
const memoryEntry = this.memoryCache.get(key);
if (memoryEntry && now - memoryEntry.timestamp < this.cacheExpiry) {
return Object.values(memoryEntry.events);
// Flatten the arrays of events
return Object.values(memoryEntry.events).flat();
}
// Try localStorage (persists between page navigations)
@ -86,7 +103,8 @@ export class EventCache {
if (now - parsedData.timestamp < this.cacheExpiry) {
// Update memory cache
this.memoryCache.set(key, parsedData);
return Object.values(parsedData.events);
// Flatten the arrays of events
return Object.values(parsedData.events).flat();
}
}
} catch (error) {

@ -35,6 +35,8 @@ export class NostrRelayService {
*/
public async connectToRelay(relayUrl: string): Promise<boolean> {
try {
console.log(`==== CONNECTING TO RELAY: ${relayUrl} ====`);
// If already connected to the requested relay, just return true
if (this.isConnected() && this.activeRelayUrl === relayUrl) {
console.log(`Already connected to relay: ${relayUrl}`);
@ -42,16 +44,24 @@ export class NostrRelayService {
return true;
}
console.log('Current connection status:', this.isConnected() ? 'Connected' : 'Disconnected');
console.log('Current active relay:', this.activeRelayUrl || 'None');
this.updateStatus('Connecting to relay...', 'connecting');
// Test the connection first
console.log('Testing connection to relay...');
const connectionSuccess = await this.wsManager.testConnection(relayUrl);
console.log('Connection test result:', connectionSuccess ? 'SUCCESS' : 'FAILED');
if (!connectionSuccess) {
this.updateStatus('Connection failed', 'error');
console.log('==== RELAY CONNECTION FAILED ====');
return false;
}
// Close existing relay pool if any
console.log('Preparing to close existing connections if needed');
if (this.relayPool && this.activeRelayUrl) {
try {
await this.relayPool.close([this.activeRelayUrl]);
@ -62,10 +72,12 @@ export class NostrRelayService {
}
// Create new relay pool
console.log('Creating new SimplePool and setting active relay URL');
this.relayPool = new nostrTools.SimplePool();
this.activeRelayUrl = relayUrl;
this.updateStatus('Connected', 'connected');
console.log(`==== RELAY CONNECTION SUCCESSFUL: ${relayUrl} ====`);
return true;
} catch (error) {
this.updateStatus(

@ -7,6 +7,8 @@ import type { NostrEvent } from '../relay';
// Import auth manager to gate network requests
import * as authManager from '../auth-manager';
// Import NIP-98 HTTP Auth types
import type { Nip98AuthEvent } from './AuthenticationService';
import type { ProfileData } from './NostrCacheService';
import { NostrCacheService } from './NostrCacheService';
@ -64,12 +66,28 @@ export class NostrService {
* @throws Error if not authenticated
*/
public async connectToRelay(relayUrl: string): Promise<boolean> {
// Ensure auth state is up-to-date with localStorage
authManager.updateAuthStateFromStorage();
// Check authentication before allowing network requests
if (!authManager.isAuthenticated()) {
console.warn('Cannot connect to relay: User not authenticated');
throw new Error('Authentication required to connect to relay');
}
// Create NIP-98 authorization headers for relay connection if possible
try {
const headers = await authManager.createAuthHeaders('GET', relayUrl);
if (headers) {
// If we got headers, pass them to the relay service
// Note: We'll need to modify the RelayService to accept headers later
console.log('Using NIP-98 auth for relay connection');
}
} catch (error) {
console.warn('Failed to create NIP-98 auth headers:', error);
// Continue without auth headers
}
return this.relayService.connectToRelay(relayUrl);
}
@ -98,12 +116,31 @@ export class NostrService {
* @returns A promise that resolves to a NostrSubscription
*/
public async subscribeToEvents(filter: NostrFilter): Promise<NostrSubscription> {
// Ensure auth state is up-to-date with localStorage
authManager.updateAuthStateFromStorage();
// Check authentication before allowing network requests
if (!authManager.isAuthenticated()) {
console.warn('Cannot subscribe to events: User not authenticated');
throw new Error('Authentication required to subscribe to events');
}
// Add signature verification for the subscription if possible
try {
const relayUrl = this.relayService.getActiveRelayUrl();
if (relayUrl) {
// Create a NIP-98 auth event specifically for this subscription
const authEvent = await authManager.authService.createAuthEvent('GET', relayUrl);
if (authEvent) {
// We could potentially add this auth info to subscription requests
console.log('Created NIP-98 auth event for subscription');
}
}
} catch (error) {
console.warn('Failed to create auth event for subscription:', error);
// Continue without auth event
}
return this.eventService.subscribeToEvents(filter);
}
@ -203,6 +240,9 @@ export class NostrService {
existingEventId?: string,
customServerPubkey?: string
): Promise<{ event: NostrEvent; serverNsec?: string } | null> {
// Ensure auth state is up-to-date with localStorage
authManager.updateAuthStateFromStorage();
// Check authentication before allowing network requests
if (!authManager.isAuthenticated()) {
console.warn('Cannot create/update 31120 event: User not authenticated');
@ -310,7 +350,21 @@ export class NostrService {
* @returns The public key or null if not found
*/
public getLoggedInPubkey(): string | null {
return localStorage.getItem('userPublicKey');
// First check with auth manager if user is authenticated
if (!authManager.isAuthenticated()) {
console.log('getLoggedInPubkey: User is not authenticated according to auth-manager');
return null;
}
// Use the new method in auth-manager to get the current user's pubkey
const pubkey = authManager.getCurrentUserPubkey();
if (!pubkey) {
console.log('getLoggedInPubkey: No pubkey available, but user is authenticated');
// Fall back to the old method
return localStorage.getItem('userPublicKey');
}
return pubkey;
}
// Cache methods delegated to NostrCacheService

@ -0,0 +1,183 @@
/**
* SecureStorageService.ts
*
* Provides a secure wrapper around browser storage with encryption capabilities,
* expiration handling, and protection against common web vulnerabilities.
*/
export interface StorageOptions {
/** Time in milliseconds after which the stored item will expire */
expiresIn?: number;
/** Whether to encrypt the stored value (default: false) */
encrypt?: boolean;
}
export interface StoredItem<T> {
/** The stored data */
data: T;
/** When the item was stored */
storedAt: number;
/** When the item expires (if applicable) */
expiresAt?: number;
}
export class SecureStorageService {
private readonly storagePrefix = 'nostr_app_';
/**
* Get an item from storage
*
* @param key The key to retrieve
* @returns The stored value or null if not found or expired
*/
public get<T>(key: string): T | null {
try {
const fullKey = this.getFullKey(key);
const storedValue = localStorage.getItem(fullKey);
if (!storedValue) {
return null;
}
const storedItem = JSON.parse(storedValue) as StoredItem<T>;
// Check if item has expired
if (storedItem.expiresAt && storedItem.expiresAt < Date.now()) {
// Remove expired item
this.remove(key);
return null;
}
return storedItem.data;
} catch (e) {
console.error('Storage access error:', e);
return null;
}
}
/**
* Set an item in storage
*
* @param key The key to store under
* @param value The value to store
* @param options Storage options including expiration
* @returns True if stored successfully
*/
public set<T>(key: string, value: T, options: StorageOptions = {}): boolean {
try {
const fullKey = this.getFullKey(key);
const storedItem: StoredItem<T> = {
data: value,
storedAt: Date.now()
};
// Add expiration if specified
if (options.expiresIn) {
storedItem.expiresAt = storedItem.storedAt + options.expiresIn;
}
// TODO: Implement encryption when options.encrypt is true
localStorage.setItem(fullKey, JSON.stringify(storedItem));
return true;
} catch (e) {
console.error('Storage write error:', e);
return false;
}
}
/**
* Remove an item from storage
*
* @param key The key to remove
* @returns True if removed successfully
*/
public remove(key: string): boolean {
try {
const fullKey = this.getFullKey(key);
localStorage.removeItem(fullKey);
return true;
} catch (e) {
console.error('Storage removal error:', e);
return false;
}
}
/**
* Check if storage has a non-expired item
*
* @param key The key to check
* @returns True if the item exists and is not expired
*/
public has(key: string): boolean {
return this.get(key) !== null;
}
/**
* Clear all items created by this service
*
* @returns True if cleared successfully
*/
public clearAll(): boolean {
try {
// Only remove items with our prefix
const keys = Object.keys(localStorage);
const ourKeys = keys.filter(key => key.startsWith(this.storagePrefix));
for (const key of ourKeys) {
localStorage.removeItem(key);
}
return true;
} catch (e) {
console.error('Storage clear error:', e);
return false;
}
}
/**
* Clean expired items from storage
*
* @returns The number of items removed
*/
public cleanExpired(): number {
try {
const keys = Object.keys(localStorage);
const ourKeys = keys.filter(key => key.startsWith(this.storagePrefix));
let removedCount = 0;
for (const fullKey of ourKeys) {
const storedValue = localStorage.getItem(fullKey);
if (!storedValue) continue;
try {
const storedItem = JSON.parse(storedValue);
if (storedItem.expiresAt && storedItem.expiresAt < Date.now()) {
localStorage.removeItem(fullKey);
removedCount++;
}
} catch {
// If we can't parse the item, consider it corrupt and remove it
localStorage.removeItem(fullKey);
removedCount++;
}
}
return removedCount;
} catch (e) {
console.error('Storage cleanup error:', e);
return 0;
}
}
/**
* Get the full key with prefix
*
* @param key The base key
* @returns The key with prefix
*/
private getFullKey(key: string): string {
return `${this.storagePrefix}${key}`;
}
}

@ -22,6 +22,7 @@ export class WebSocketManager {
public async connect(url: string, options: WebSocketOptions = {}): Promise<WebSocket> {
return new Promise<WebSocket>((resolve, reject) => {
// Safely close any existing connection first
console.log(`WebSocketManager: Closing any existing connections before connecting to ${url}`);
this.close();
let timeoutId: number | undefined;
@ -32,7 +33,7 @@ export class WebSocketManager {
this.ws = new WebSocket(url);
this.connected = false;
console.log(`Attempting WebSocket connection to: ${url}`);
console.log(`WebSocketManager: Attempting WebSocket connection to: ${url}`);
// Set a timeout for the connection attempt
timeoutId = window.setTimeout(() => {
@ -48,24 +49,41 @@ export class WebSocketManager {
this.ws.onopen = () => {
if (timeoutId) clearTimeout(timeoutId);
this.connected = true;
console.log(`WebSocketManager: Connection to ${url} established successfully`);
if (options.onOpen && this.ws) {
console.log('WebSocketManager: Calling onOpen callback');
options.onOpen(this.ws);
}
resolve(this.ws as WebSocket);
};
this.ws.onmessage = (msg) => {
console.log(`WebSocketManager: Message received from ${url}`);
if (options.onMessage && typeof msg.data === 'string') {
try {
const parsedData = JSON.parse(msg.data);
const msgType = Array.isArray(parsedData) && parsedData.length > 0 ? parsedData[0] : 'unknown';
console.log(`WebSocketManager: Parsed message data type: ${msgType}`);
// For EVENT messages, log additional details
if (msgType === 'EVENT' && Array.isArray(parsedData) && parsedData.length >= 3) {
const eventObj = parsedData[2];
if (eventObj && eventObj.kind) {
console.log(`WebSocketManager: Received event of kind: ${eventObj.kind}`);
}
}
options.onMessage(parsedData);
} catch {
} catch (error) {
console.error('WebSocketManager: Error parsing message:', error);
console.error('WebSocketManager: Raw message:', msg.data.substring(0, 200));
// Ignore parsing errors
}
}
};
this.ws.onerror = (errorEvt) => {
console.error(`WebSocketManager: Error on connection to ${url}:`, errorEvt);
if (timeoutId) clearTimeout(timeoutId);
if (options.onError) {
options.onError(errorEvt);
@ -75,10 +93,20 @@ export class WebSocketManager {
}
};
this.ws.onclose = () => {
this.ws.onclose = (evt) => {
console.log(`WebSocketManager: Connection to ${url} closed. Code: ${evt.code}, Reason: ${evt.reason || 'No reason provided'}, Clean: ${evt.wasClean}`);
if (timeoutId) clearTimeout(timeoutId);
this.connected = false;
// Log the closing information more thoroughly
if (evt.wasClean) {
console.log('WebSocketManager: Connection closed cleanly');
} else {
console.warn('WebSocketManager: Connection closed unexpectedly');
}
if (options.onClose) {
console.log('WebSocketManager: Calling onClose callback');
options.onClose();
}
};
@ -150,14 +178,19 @@ export class WebSocketManager {
*/
public close(): void {
if (this.ws) {
console.log(`WebSocketManager: Closing connection to ${this.url || 'unknown'}`);
try {
this.ws.close();
} catch {
console.log('WebSocketManager: WebSocket close() called successfully');
} catch (error) {
console.error('WebSocketManager: Error while closing WebSocket:', error);
// Ignore errors when closing WebSocket
}
this.ws = null;
this.connected = false;
this.url = null;
} else {
console.log('WebSocketManager: No active connection to close');
}
}

@ -2,7 +2,7 @@
import type { NostrEvent } from '../relay';
// Define the extension interfaces here to avoid conflicts
interface WindowNostrExtension {
export interface WindowNostrExtension {
/**
* Get the user's public key from the Nostr extension
* @returns The user's public key as a hex string
@ -16,6 +16,14 @@ interface WindowNostrExtension {
*/
signEvent: (event: Partial<NostrEvent>) => Promise<NostrEvent>;
/**
* Sign an arbitrary string using Schnorr signature
* (as per NIP-98 spec)
* @param message The message to sign
* @returns The signature as a hex string
*/
signSchnorr?: (message: string) => Promise<string>;
/**
* NIP-44 encryption methods (may not be available in all extensions)
*/
@ -30,7 +38,11 @@ interface WindowNostrExtension {
declare global {
// Augment the Window interface but don't redefine the nostr property
interface Window {
// This is intentionally left empty
// Declare the nostr property for TypeScript to recognize it
nostr?: WindowNostrExtension;
// Add global authentication state tracking
authInProgress?: boolean;
currentSignedEvent?: NostrEvent;
}
}

@ -2922,4 +2922,49 @@ footer {
}
/* Import styles for the 21121 Response Creator component */
@import url('./styles/response-creator.css');
@import url('./styles/response-creator.css');
/* Login status indicator */
.status-indicator {
display: inline-block;
padding: 4px 10px;
border-radius: 12px;
font-size: 0.8rem;
margin-left: 10px;
user-select: none;
}
.status-indicator.logged-in {
background-color: #4caf50;
color: white;
}
.status-indicator.logged-out {
background-color: #f44336;
color: white;
}
.disabled-button {
background-color: #cccccc;
color: #666666;
cursor: pointer;
}
.disabled-button:hover {
background-color: #bbbbbb;
}
/* Authentication required message styling */
.auth-required-message {
background-color: var(--bg-info);
border-radius: 8px;
padding: 20px;
margin: 20px 0;
border-left: 4px solid var(--accent-color);
text-align: center;
}
.auth-required-message h3 {
color: var(--accent-color);
margin-top: 0;
}