diff --git a/src/constants.ts b/src/constants.ts index 0f9115b8..cc280827 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -518,8 +518,7 @@ export const PROFILE_RELAY_URLS = [ 'wss://purplepag.es', 'wss://profiles.nostrver.se/', 'wss://indexer.coracle.social/', - 'wss://relay.primal.net', - 'wss://relay.damus.io' + 'wss://thecitadel.nostr1.com' ] export const FOLLOWS_HISTORY_RELAY_URLS = [ diff --git a/src/lib/read-only-relay-personal.test.ts b/src/lib/read-only-relay-personal.test.ts index 0a84b18a..190310b2 100644 --- a/src/lib/read-only-relay-personal.test.ts +++ b/src/lib/read-only-relay-personal.test.ts @@ -84,7 +84,7 @@ describe('read-only-relay-personal', () => { ] expect(sanitizeRelayUrlsForFetch(urls)).toEqual(urls) expect(isRelayConnectionAllowedForViewer('wss://profiles.nostr1.com/')).toBe(true) - expect(isRelayConnectionAllowedForViewer('wss://relay.damus.io/')).toBe(true) + expect(isRelayConnectionAllowedForViewer('wss://thecitadel.nostr1.com/')).toBe(true) expect(isRelayConnectionAllowedForViewer('wss://relay.example.com/')).toBe(true) expect(isRelayConnectionAllowedForViewer('wss://theforest.nostr1.com/')).toBe(false) expect(isRelayConnectionAllowedForViewer('wss://nostr.wirednet.jp/')).toBe(false) @@ -107,10 +107,25 @@ describe('read-only-relay-personal', () => { it('metadata-only policy allows profile bootstrap relays at connect time', () => { setRestrictConnectionsToMetadataRelaysOnly(true) setViewerPersonalRelayKeys(new Set(), { viewerActive: true }) - expect(isRelayConnectionAllowedForViewer('wss://relay.damus.io/')).toBe(true) + expect(isRelayConnectionAllowedForViewer('wss://thecitadel.nostr1.com/')).toBe(true) expect(isRelayConnectionAllowedForViewer('wss://profiles.nostr1.com/')).toBe(true) }) + it('metadata-only policy allows viewer cache and HTTP index relays', () => { + setRestrictConnectionsToMetadataRelaysOnly(true) + setViewerPersonalRelayKeys( + buildPersonalRelayKeySet([ + 'ws://localhost:4869/', + 'https://index.example.com/', + 'wss://nostr.land/' + ]), + { viewerActive: true } + ) + expect(isRelayConnectionAllowedForViewer('ws://localhost:4869/')).toBe(true) + expect(isRelayConnectionAllowedForViewer('https://index.example.com/')).toBe(true) + expect(isRelayConnectionAllowedForViewer('wss://theforest.nostr1.com/')).toBe(false) + }) + it('metadata-only bypass allows relays outside personal lists', () => { setRestrictConnectionsToMetadataRelaysOnly(true) setViewerPersonalRelayKeys(buildPersonalRelayKeySet(['wss://nostr.land/']), { viewerActive: true }) diff --git a/src/lib/relay-strikes.test.ts b/src/lib/relay-strikes.test.ts index 3b00ab53..fb27722d 100644 --- a/src/lib/relay-strikes.test.ts +++ b/src/lib/relay-strikes.test.ts @@ -57,6 +57,29 @@ describe('relaySessionStrikes HTTP read failures', () => { }) }) +describe('relaySessionStrikes cache and localhost', () => { + beforeEach(() => { + relaySessionStrikes.reset() + }) + + it('session-skips cache relay after two connection failures', () => { + const url = 'ws://localhost:4869/' + relaySessionStrikes.setSessionCacheRelayKeysFromKind10432({ + kind: 10432, + tags: [['relay', url]], + content: '', + created_at: 1, + id: 'a'.repeat(64), + pubkey: 'b'.repeat(64), + sig: 'c'.repeat(128) + }) + relaySessionStrikes.recordReadFailure(url, 'connection') + expect(relaySessionStrikes.isReadHttpSkipped(url)).toBe(false) + relaySessionStrikes.recordReadFailure(url, 'connection') + expect(relaySessionStrikes.isReadHttpSkipped(url)).toBe(true) + }) +}) + describe('relaySessionStrikes.clearKey', () => { beforeEach(() => { relaySessionStrikes.reset() diff --git a/src/lib/relay-strikes.ts b/src/lib/relay-strikes.ts index f65586f1..cfb8f5d3 100644 --- a/src/lib/relay-strikes.ts +++ b/src/lib/relay-strikes.ts @@ -7,11 +7,15 @@ import { import type { Event } from 'nostr-tools' import { getRelayListFromEvent } from '@/lib/event-metadata' import logger from '@/lib/logger' -import { canonicalRelaySessionKey, httpIndexRelayBasesInUrlBatch } from '@/lib/url' +import { canonicalRelaySessionKey, httpIndexRelayBasesInUrlBatch, isLocalNetworkUrl } from '@/lib/url' import type { RelayOpTerminalRow } from '@/services/relay-operation-log.service' /** Conservative: 5 read/publish failures → skip until this many ms after last qualifying failure. */ const STRIKE_FAILURES_THRESHOLD = 5 +/** Kind 10432 cache relays (often localhost): skip after fewer failures — no point hammering a dead socket. */ +const CACHE_RELAY_STRIKE_FAILURES_THRESHOLD = 2 +/** LAN / loopback WS: same fast skip on connection refused. */ +const LOCAL_NETWORK_STRIKE_FAILURES_THRESHOLD = 2 const STRIKE_COOLDOWN_MS = 3 * 60 * 1000 /** Rate-limit style NOTICE / overload → cool down without incrementing strike counter. */ @@ -160,13 +164,17 @@ class RelaySessionStrikes { } /** WS connect failure, HTTP transport failure, etc. */ - recordReadFailure(url: string, _source: 'connection' | 'notice' | 'http'): void { + recordReadFailure(url: string, source: 'connection' | 'notice' | 'http'): void { const key = sessionKey(url) if (!key) return - this.recordReadFailureKey(key, _source) + this.recordReadFailureKey(key, source, url) } - private recordReadFailureKey(key: string, _source: 'connection' | 'notice' | 'http'): void { + private recordReadFailureKey( + key: string, + source: 'connection' | 'notice' | 'http', + urlForLocalCheck?: string + ): void { const now = Date.now() const e = this.getEntry(key) // During rate-limit cooldown, do not add strikes for normal relays (relay can catch up). @@ -177,8 +185,8 @@ class RelaySessionStrikes { // HTTP index failures often arrive in parallel; count each so session skip engages quickly. // Connection refused / unreachable: do not debounce — profile feeds open many relays at once. if ( - _source !== 'http' && - _source !== 'connection' && + source !== 'http' && + source !== 'connection' && now - e.readLastStrikeIncrementAt < STRIKE_INCREMENT_DEBOUNCE_MS ) { return @@ -187,12 +195,25 @@ class RelaySessionStrikes { } e.readFailures += 1 - if (e.readFailures >= STRIKE_FAILURES_THRESHOLD) { + const threshold = this.readStrikeThresholdForKey(key, source, urlForLocalCheck) + if (e.readFailures >= threshold) { e.readStrikeSkipUntil = Math.max(e.readStrikeSkipUntil, now + STRIKE_COOLDOWN_MS) - logger.debug('[RelayStrikes] read path strike skip', { key, readFailures: e.readFailures }) + logger.debug('[RelayStrikes] read path strike skip', { key, readFailures: e.readFailures, threshold }) } } + private readStrikeThresholdForKey( + key: string, + source: 'connection' | 'notice' | 'http', + urlForLocalCheck?: string + ): number { + if (this.cacheRelayKeys.has(key)) return CACHE_RELAY_STRIKE_FAILURES_THRESHOLD + if (source === 'connection' && urlForLocalCheck && isLocalNetworkUrl(urlForLocalCheck)) { + return LOCAL_NETWORK_STRIKE_FAILURES_THRESHOLD + } + return STRIKE_FAILURES_THRESHOLD + } + recordReadSuccess(url: string): void { const key = sessionKey(url) if (!key) return @@ -238,7 +259,7 @@ class RelaySessionStrikes { if (timedOut || slowEose) { const parked = this.recordSlowSignalKey(key, now) if (parked) socketsToClose.push(row.relayUrl) - if (timedOut) this.recordReadFailureKey(key, 'connection') + if (timedOut) this.recordReadFailureKey(key, 'connection', row.relayUrl) continue } @@ -305,8 +326,7 @@ class RelaySessionStrikes { const http = httpIndexRelayBasesInUrlBatch(urls, httpIndexBases) const httpKeys = new Set(http.map((u) => canonicalRelaySessionKey(u))) const ws = urls.filter((u) => !httpKeys.has(canonicalRelaySessionKey(u))) - const singleWsRelay = ws.length <= 1 - const wsOut = singleWsRelay ? [...ws] : ws.filter((u) => !this.isReadHttpSkipped(u)) + const wsOut = ws.filter((u) => !this.isReadHttpSkipped(u)) const httpOut = http.filter((u) => !this.isReadHttpSkipped(u)) const merged = [...wsOut, ...httpOut] return merged.length > 0 ? merged : [...urls] diff --git a/src/services/client.service.ts b/src/services/client.service.ts index de7c17ab..36982043 100644 --- a/src/services/client.service.ts +++ b/src/services/client.service.ts @@ -450,6 +450,9 @@ class ClientService extends EventTarget { if (params?.purpose !== 'write' && !isRelayConnectionAllowedForViewer(url)) { throw new Error(`[metadata-relays-only] skipping relay ${url}`) } + if (params?.purpose !== 'write' && relaySessionStrikes.isReadHttpSkipped(url)) { + throw new Error(`[relay-strike] skipping unresponsive relay ${url}`) + } if (!isWebsocketUrl(url) && isKind10243HttpRelayTagUrl(url)) { throw new Error(`[http-index-relay] ${url} uses the HTTPS index API, not WebSocket`) } @@ -458,10 +461,25 @@ class ClientService extends EventTarget { const connectionTimeout = READ_ONLY_RELAY_CONNECT_BOOST_URLS.has(n) ? Math.max(base, RELAY_READ_ONLY_POOL_CONNECT_TIMEOUT_MS) : base - const relay = await rawEnsureRelay(url, { - ...params, - connectionTimeout - }) + let relay + try { + relay = await rawEnsureRelay(url, { + ...params, + connectionTimeout + }) + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + if ( + params?.purpose !== 'write' && + !msg.includes('[metadata-relays-only]') && + !msg.includes('[relay-strike]') && + !msg.includes('[offline]') && + !msg.includes('[http-index-relay]') + ) { + relaySessionStrikes.recordReadFailure(url, 'connection') + } + throw err + } patchPoolRelayAuthRaceAndFeedback(relay) applyRelayNip42AckTimeout(relay) touchRelayPoolActivity(url) @@ -631,22 +649,50 @@ class ClientService extends EventTarget { setViewerPersonalRelayKeys(new Set(), { viewerActive: false }) syncViewerRelayStackNostrLandAggrEligible([]) setViewerBlockedRelayUrls([]) + relaySessionStrikes.setSessionCacheRelayKeysFromKind10432(null) return } - /** Engage policy before any await so session hydrate cannot open PROFILE/FAST_WRITE stacks first. */ - if (isRestrictConnectionsToMetadataRelaysOnly()) { - setViewerPersonalRelayKeys(new Set(), { viewerActive: true }) - } + + /** IndexedDB-first: personal lists (incl. cache + HTTP) before policy or network so locals stay allowed. */ + const storageUrls = await this.collectViewerPersonalRelayUrlsFromStorage(pk) + this.viewerHttpIndexRelayBases = storageUrls.httpIndexBases + setViewerPersonalRelayKeys(buildPersonalRelayKeySet(storageUrls.all), { + viewerActive: isRestrictConnectionsToMetadataRelaysOnly() + }) + syncViewerRelayStackNostrLandAggrEligible(storageUrls.all) + relaySessionStrikes.setSessionCacheRelayKeysFromKind10432(storageUrls.cacheRelayEvent) + this.closeMetadataPolicyDisallowedRelayConnections() + try { const blockedEvt = await indexedDb.getReplaceableEvent(pk, ExtendedKind.BLOCKED_RELAYS) setViewerBlockedRelayUrls(parseBlockedRelayUrlsFromEvent(blockedEvt ?? null)) } catch { setViewerBlockedRelayUrls([]) } + + const urls = [...storageUrls.all] + try { + urls.push(...(await this.fetchFavoriteRelays(pk))) + } catch { + // ignore + } + setViewerPersonalRelayKeys(buildPersonalRelayKeySet(urls), { viewerActive: true }) + syncViewerRelayStackNostrLandAggrEligible(urls) + this.closeMetadataPolicyDisallowedRelayConnections() + } + + /** NIP-65 / 10243 / 10432 / favorites (10012) from IndexedDB only — no network. */ + private async collectViewerPersonalRelayUrlsFromStorage(pubkey: string): Promise<{ + all: string[] + httpIndexBases: string[] + cacheRelayEvent: NEvent | undefined + }> { const urls: string[] = [] + let httpIndexBases: string[] = [] + let cacheRelayEvent: NEvent | undefined try { - const rl = await this.peekRelayListFromStorage(pk) - this.viewerHttpIndexRelayBases = [...(rl.httpRead ?? []), ...(rl.httpWrite ?? [])] + const rl = await this.peekRelayListFromStorage(pubkey) + httpIndexBases = [...(rl.httpRead ?? []), ...(rl.httpWrite ?? [])] .map((u) => normalizeHttpRelayUrl(u) || u) .filter(Boolean) urls.push( @@ -656,22 +702,32 @@ class ClientService extends EventTarget { ...(rl.httpWrite ?? []) ) } catch { - this.viewerHttpIndexRelayBases = [] - // ignore + httpIndexBases = [] } try { - urls.push(...(await this.fetchFavoriteRelays(pk))) + urls.push(...(await this.fetchFavoriteRelaysFromStorage(pubkey))) } catch { // ignore } try { - urls.push(...(await getCacheRelayUrls(pk))) + cacheRelayEvent = (await indexedDb.getReplaceableEvent(pubkey, ExtendedKind.CACHE_RELAYS)) ?? undefined + urls.push(...(await getCacheRelayUrls(pubkey))) } catch { - // ignore + cacheRelayEvent = undefined + } + const all = Array.from(new Set(urls.map((u) => u.trim()).filter(Boolean))) + return { all, httpIndexBases, cacheRelayEvent } + } + + /** Kind 10012 + embedded NIP-51 relay sets from IndexedDB only. */ + private async fetchFavoriteRelaysFromStorage(pubkey: string): Promise { + try { + const favoriteRelaysEvent = await indexedDb.getReplaceableEvent(pubkey, ExtendedKind.FAVORITE_RELAYS) + if (!favoriteRelaysEvent) return [] + return await this.expandFavoriteRelayUrlsFromEvent(pubkey, favoriteRelaysEvent) + } catch { + return [] } - setViewerPersonalRelayKeys(buildPersonalRelayKeySet(urls), { viewerActive: true }) - syncViewerRelayStackNostrLandAggrEligible(urls) - this.closeMetadataPolicyDisallowedRelayConnections() } /** Drop pooled WebSocket connections that violate the metadata-only read policy. */ @@ -3565,49 +3621,57 @@ class ClientService extends EventTarget { async fetchFavoriteRelays(pubkey: string): Promise { try { - const favoriteRelaysEvent = await this.replaceableEventService.fetchReplaceableEvent(pubkey, ExtendedKind.FAVORITE_RELAYS) + const favoriteRelaysEvent = await this.replaceableEventService.fetchReplaceableEvent( + pubkey, + ExtendedKind.FAVORITE_RELAYS + ) if (!favoriteRelaysEvent) return [] + return await this.expandFavoriteRelayUrlsFromEvent(pubkey, favoriteRelaysEvent) + } catch { + return [] + } + } - const relays: string[] = [] - const relaySetIds: string[] = [] - favoriteRelaysEvent.tags.forEach(([tagName, tagValue]) => { - if (tagName === 'relay' && tagValue) { - const normalized = normalizeUrl(tagValue) - if (normalized) { - relays.push(normalized) - } - } else if (tagName === 'a' && tagValue) { - const [kindStr, author, d] = tagValue.split(':') - if ( - kindStr === String(kinds.Relaysets) && - author === pubkey && - d && - !relaySetIds.includes(d) - ) { - relaySetIds.push(d) - } + private async expandFavoriteRelayUrlsFromEvent( + pubkey: string, + favoriteRelaysEvent: NEvent + ): Promise { + const relays: string[] = [] + const relaySetIds: string[] = [] + favoriteRelaysEvent.tags.forEach(([tagName, tagValue]) => { + if (tagName === 'relay' && tagValue) { + const normalized = normalizeUrl(tagValue) + if (normalized) { + relays.push(normalized) } - }) - - // NIP-51 relay sets on kind 10012: same expansion as {@link FavoriteRelaysProvider} (not only `relay` tags). - for (const id of relaySetIds) { - try { - const ev = await indexedDb.getReplaceableEvent(pubkey, kinds.Relaysets, id) - if (!ev || shouldDropEventOnIngest(ev)) continue - const set = getRelaySetFromEvent(ev) - for (const u of set.relayUrls) { - const n = normalizeUrl(u) || normalizeAnyRelayUrl(u) - if (n && !relays.includes(n)) relays.push(n) - } - } catch { - /* ignore */ + } else if (tagName === 'a' && tagValue) { + const [kindStr, author, d] = tagValue.split(':') + if ( + kindStr === String(kinds.Relaysets) && + author === pubkey && + d && + !relaySetIds.includes(d) + ) { + relaySetIds.push(d) } } + }) - return Array.from(new Set(relays)) - } catch { - return [] + for (const id of relaySetIds) { + try { + const ev = await indexedDb.getReplaceableEvent(pubkey, kinds.Relaysets, id) + if (!ev || shouldDropEventOnIngest(ev)) continue + const set = getRelaySetFromEvent(ev) + for (const u of set.relayUrls) { + const n = normalizeUrl(u) || normalizeAnyRelayUrl(u) + if (n && !relays.includes(n)) relays.push(n) + } + } catch { + /* ignore */ + } } + + return Array.from(new Set(relays)) }