From 8a40837d057d0c8c17dbe24f48a60f709668cb0c Mon Sep 17 00:00:00 2001 From: Silberengel Date: Wed, 4 Feb 2026 07:02:49 +0100 Subject: [PATCH] implement auth --- src/lib/services/nostr/config.ts | 6 +- src/lib/services/nostr/nostr-client.ts | 301 ++++++++++++++++++++----- 2 files changed, 245 insertions(+), 62 deletions(-) diff --git a/src/lib/services/nostr/config.ts b/src/lib/services/nostr/config.ts index 98be740..b7e2178 100644 --- a/src/lib/services/nostr/config.ts +++ b/src/lib/services/nostr/config.ts @@ -8,13 +8,15 @@ const DEFAULT_RELAYS = [ 'wss://nostr21.com', 'wss://nostr.land', 'wss://nostr.sovbit.host', - 'wss://orly-relay.imwald.eu' + 'wss://orly-relay.imwald.eu', + 'wss://nostr.wine' ]; const PROFILE_RELAYS = [ 'wss://relay.damus.io', 'wss://aggr.nostr.land', - 'wss://profiles.nostr1.com' + 'wss://profiles.nostr1.com', + 'wss://relay.primal.net' ]; const THREAD_PUBLISH_RELAYS = [ diff --git a/src/lib/services/nostr/nostr-client.ts b/src/lib/services/nostr/nostr-client.ts index 296ec9f..fbd4d89 100644 --- a/src/lib/services/nostr/nostr-client.ts +++ b/src/lib/services/nostr/nostr-client.ts @@ -3,12 +3,13 @@ * Features: request throttling, batching, rate limiting, efficient caching */ -import { Relay, type Filter, matchFilter } from 'nostr-tools'; +import { Relay, type Filter, matchFilter, type EventTemplate, type VerifiedEvent } from 'nostr-tools'; import { config } from './config.js'; import type { NostrEvent } from '../../types/nostr.js'; import { cacheEvent, cacheEvents, getEvent, getEventsByKind, getEventsByPubkey } from '../cache/event-cache.js'; import { getDB } from '../cache/indexeddb-store.js'; import { filterEvents, shouldHideEvent } from '../event-filter.js'; +import { sessionManager } from '../auth/session-manager.js'; export interface PublishOptions { relays?: string[]; @@ -47,6 +48,9 @@ class NostrClient { private readonly INITIAL_RETRY_DELAY = 5000; // 5 seconds private readonly MAX_RETRY_DELAY = 300000; // 5 minutes private readonly MAX_FAILURE_COUNT = 10; // After 10 failures, wait max delay + + // Track authenticated relays to avoid re-authenticating + private authenticatedRelays: Set = new Set(); async initialize(): Promise { if (this.initialized) return; @@ -69,6 +73,61 @@ class NostrClient { this.initialized = true; } + /** + * Authenticate with a relay using NIP-42 AUTH + * Only call this when the relay has sent an AUTH challenge or an operation failed with auth-required + */ + private async authenticateRelay(relay: Relay, url: string): Promise { + // Check if we're already authenticated + if (this.authenticatedRelays.has(url)) { + return true; + } + + // Check if user is logged in + const session = sessionManager.getSession(); + if (!session) { + console.debug(`[nostr-client] Cannot authenticate with ${url}: user not logged in`); + return false; + } + + try { + // Check if relay supports AUTH by checking for auth method + // nostr-tools Relay.auth() will handle the AUTH flow + // Only call this when relay has sent a challenge (handled by nostr-tools) + if (typeof (relay as any).auth === 'function') { + await relay.auth(async (authEvent: EventTemplate): Promise => { + // Ensure authEvent has required fields + const eventToSign: Omit = { + pubkey: session.pubkey, + created_at: authEvent.created_at, + kind: authEvent.kind, + tags: authEvent.tags || [], + content: authEvent.content || '' + }; + + // Sign the auth event using the session signer + const signedEvent = await session.signer(eventToSign); + + // Return as VerifiedEvent (nostr-tools will verify the signature) + return signedEvent as VerifiedEvent; + }); + + this.authenticatedRelays.add(url); + console.debug(`[nostr-client] Successfully authenticated with relay: ${url}`); + return true; + } + } catch (error) { + // Only log if it's not the "no challenge" error (which is expected for relays that don't require auth) + const errorMessage = error instanceof Error ? error.message : String(error); + if (!errorMessage.includes('no challenge was received')) { + console.debug(`[nostr-client] Failed to authenticate with relay ${url}:`, errorMessage); + } + return false; + } + + return false; + } + async addRelay(url: string): Promise { if (this.relays.has(url)) return; @@ -86,6 +145,11 @@ class NostrClient { try { const relay = await Relay.connect(url); this.relays.set(url, relay); + + // Don't proactively authenticate - only authenticate when: + // 1. Relay sends an AUTH challenge (handled by nostr-tools automatically) + // 2. An operation fails with 'auth-required' error (handled in publish/subscribe methods) + // Clear failure tracking on successful connection this.failedRelays.delete(url); // Log successful connection at debug level to reduce console noise @@ -94,8 +158,17 @@ class NostrClient { // Track the failure const existingFailure = this.failedRelays.get(url) || { lastFailure: 0, retryAfter: this.INITIAL_RETRY_DELAY, failureCount: 0 }; const failureCount = existingFailure.failureCount + 1; + + // For connection refused errors, use longer backoff to avoid spam + const errorMessage = error instanceof Error ? error.message : String(error); + const isConnectionRefused = errorMessage.includes('CONNECTION_REFUSED') || + errorMessage.includes('connection refused') || + errorMessage.includes('Connection refused'); + + // Use longer initial delay for connection refused errors + const initialDelay = isConnectionRefused ? this.INITIAL_RETRY_DELAY * 2 : this.INITIAL_RETRY_DELAY; const retryAfter = Math.min( - this.INITIAL_RETRY_DELAY * Math.pow(2, Math.min(failureCount - 1, 6)), // Exponential backoff, max 2^6 = 64x + initialDelay * Math.pow(2, Math.min(failureCount - 1, 6)), // Exponential backoff, max 2^6 = 64x this.MAX_RETRY_DELAY ); @@ -105,7 +178,11 @@ class NostrClient { failureCount }); - console.warn(`[nostr-client] Failed to connect to relay ${url} (failure #${failureCount}), will retry after ${Math.round(retryAfter / 1000)}s`); + // Only log at debug level to reduce console noise - connection failures are expected + // Only warn if it's a persistent failure (after several attempts) + if (failureCount > 3) { + console.debug(`[nostr-client] Relay ${url} connection failed (failure #${failureCount}), will retry after ${Math.round(retryAfter / 1000)}s`); + } throw error; } } @@ -119,6 +196,8 @@ class NostrClient { // Ignore } this.relays.delete(url); + // Remove from authenticated relays when disconnecting + this.authenticatedRelays.delete(url); } } @@ -328,10 +407,32 @@ class NostrClient { await newRelay.publish(event); results.success.push(url); } catch (error) { - results.failed.push({ - relay: url, - error: error instanceof Error ? error.message : 'Unknown error' - }); + // Check if error is auth-required + if (error instanceof Error && error.message.startsWith('auth-required')) { + // Try to authenticate and retry + const authSuccess = await this.authenticateRelay(newRelay, url); + if (authSuccess) { + try { + await newRelay.publish(event); + results.success.push(url); + } catch (retryError) { + results.failed.push({ + relay: url, + error: retryError instanceof Error ? retryError.message : 'Unknown error' + }); + } + } else { + results.failed.push({ + relay: url, + error: 'Authentication required but failed' + }); + } + } else { + results.failed.push({ + relay: url, + error: error instanceof Error ? error.message : 'Unknown error' + }); + } } } } catch (error) { @@ -345,10 +446,32 @@ class NostrClient { await relay.publish(event); results.success.push(url); } catch (error) { - results.failed.push({ - relay: url, - error: error instanceof Error ? error.message : 'Unknown error' - }); + // Check if error is auth-required + if (error instanceof Error && error.message.startsWith('auth-required')) { + // Try to authenticate and retry + const authSuccess = await this.authenticateRelay(relay, url); + if (authSuccess) { + try { + await relay.publish(event); + results.success.push(url); + } catch (retryError) { + results.failed.push({ + relay: url, + error: retryError instanceof Error ? retryError.message : 'Unknown error' + }); + } + } else { + results.failed.push({ + relay: url, + error: 'Authentication required but failed' + }); + } + } else { + results.failed.push({ + relay: url, + error: error instanceof Error ? error.message : 'Unknown error' + }); + } } } } @@ -402,28 +525,53 @@ class NostrClient { try { const client = this; - const sub = relay.subscribe(filters, { - onevent: (event: NostrEvent) => { - try { - if (!client.relays.has(url)) return; - if (client.shouldFilterZapReceipt(event)) return; - client.addToCache(event); - onEvent(event, url); - } catch (err) { - // Silently handle errors - connection may be closed - } - }, - oneose: () => { - try { - if (!client.relays.has(url)) return; - onEose?.(url); - } catch (err) { - // Silently handle errors - connection may be closed + let hasAuthed = this.authenticatedRelays.has(url); + + const startSub = () => { + const sub = relay.subscribe(filters, { + onevent: (event: NostrEvent) => { + try { + if (!client.relays.has(url)) return; + if (client.shouldFilterZapReceipt(event)) return; + client.addToCache(event); + onEvent(event, url); + } catch (err) { + // Silently handle errors - connection may be closed + } + }, + oneose: () => { + try { + if (!client.relays.has(url)) return; + onEose?.(url); + } catch (err) { + // Silently handle errors - connection may be closed + } + }, + onclose: (reason: string) => { + // Handle auth-required on subscription close + if (reason.startsWith('auth-required') && !hasAuthed) { + const session = sessionManager.getSession(); + if (session) { + client.authenticateRelay(relay, url) + .then((authSuccess) => { + if (authSuccess) { + hasAuthed = true; + // Retry subscription after authentication + startSub(); + } + }) + .catch(() => { + // Authentication failed, give up + }); + } + } } - } - }); + }); - this.subscriptions.set(`${url}_${subId}`, { relay, sub }); + client.subscriptions.set(`${url}_${subId}`, { relay, sub }); + }; + + startSub(); } catch (error) { // Handle errors } @@ -511,6 +659,7 @@ class NostrClient { const subId = `sub_${this.nextSubId++}_${Date.now()}`; let resolved = false; let timeoutId: ReturnType | null = null; + let hasAuthed = this.authenticatedRelays.has(relayUrl); const finish = () => { if (resolved) return; @@ -519,37 +668,66 @@ class NostrClient { this.unsubscribe(subId); }; - try { - const client = this; - const sub = relay.subscribe(filters, { - onevent: (event: NostrEvent) => { - try { - if (!client.relays.has(relayUrl)) return; - if (shouldHideEvent(event)) return; - if (client.shouldFilterZapReceipt(event)) return; - events.set(event.id, event); - client.addToCache(event); - } catch (error) { - // Silently handle errors - connection may be closed - } - }, - oneose: () => { - try { - if (!resolved) finish(); - } catch (error) { - // Silently handle errors - connection may be closed + const startSub = () => { + try { + const client = this; + const sub = relay.subscribe(filters, { + onevent: (event: NostrEvent) => { + try { + if (!client.relays.has(relayUrl)) return; + if (shouldHideEvent(event)) return; + if (client.shouldFilterZapReceipt(event)) return; + events.set(event.id, event); + client.addToCache(event); + } catch (error) { + // Silently handle errors - connection may be closed + } + }, + oneose: () => { + try { + if (!resolved) finish(); + } catch (error) { + // Silently handle errors - connection may be closed + } + }, + onclose: (reason: string) => { + // Handle auth-required on subscription close + if (reason.startsWith('auth-required') && !hasAuthed && !resolved) { + const session = sessionManager.getSession(); + if (session) { + client.authenticateRelay(relay, relayUrl) + .then((authSuccess) => { + if (authSuccess) { + hasAuthed = true; + // Retry subscription after authentication + if (!resolved) { + startSub(); + } + } else { + finish(); + } + }) + .catch(() => { + finish(); + }); + } else { + finish(); + } + } } - } - }); + }); - this.subscriptions.set(`${relayUrl}_${subId}`, { relay, sub }); + client.subscriptions.set(`${relayUrl}_${subId}`, { relay, sub }); - timeoutId = setTimeout(() => { - if (!resolved) finish(); - }, timeout); - } catch (error) { - finish(); - } + timeoutId = setTimeout(() => { + if (!resolved) finish(); + }, timeout); + } catch (error) { + finish(); + } + }; + + startSub(); } private processQueue(): void { @@ -672,7 +850,10 @@ class NostrClient { return []; } - console.debug(`[nostr-client] Fetching from ${connectedRelays.length} connected relay(s) out of ${relays.length} requested`); + // Only log if we're missing a significant number of relays + if (connectedRelays.length < relays.length * 0.5) { + console.debug(`[nostr-client] Fetching from ${connectedRelays.length} connected relay(s) out of ${relays.length} requested`); + } // Process relays sequentially with throttling to avoid overload const events: Map = new Map();