diff --git a/src/components/Embedded/EmbeddedNote.tsx b/src/components/Embedded/EmbeddedNote.tsx index 99dfa837..25c0331f 100644 --- a/src/components/Embedded/EmbeddedNote.tsx +++ b/src/components/Embedded/EmbeddedNote.tsx @@ -19,6 +19,7 @@ import nip66Service from '@/services/nip66.service' import { navigationEventStore } from '@/services/navigation-event-store' import { useViewerInboxRelayUrls } from '@/hooks/useViewerInboxRelayUrls' import { feedRelayPolicyUrls } from '@/features/feed/relay-policy' +import { filterReadOnlyRelaysUnlessPersonal } from '@/lib/read-only-relay-personal' import { useFavoriteRelays } from '@/providers/favorite-relays-context' import { useDeletedEvent } from '@/providers/DeletedEventProvider' import { useReply } from '@/providers/ReplyProvider' @@ -535,28 +536,30 @@ function buildEmbedWideRelayUrlsStatic( relayHintsFromParent: string[], viewerInboxRelayUrls: string[] ): string[] { - return feedRelayPolicyUrls( - [ + return filterReadOnlyRelaysUnlessPersonal( + feedRelayPolicyUrls( + [ + { + source: 'fallback', + urls: preferPublicIndexRelaysFirst( + dedupeRelayUrls([ + ...relayHintsFromParent, + ...viewerInboxRelayUrls, + ...nip66Service.getSearchableRelayUrls(), + ...SEARCHABLE_RELAY_URLS, + ...FAST_READ_RELAY_URLS, + ...PROFILE_RELAY_URLS, + ...menuRelayUrls + ]) + ) + } + ], { - source: 'fallback', - urls: preferPublicIndexRelaysFirst( - dedupeRelayUrls([ - ...relayHintsFromParent, - ...viewerInboxRelayUrls, - ...nip66Service.getSearchableRelayUrls(), - ...SEARCHABLE_RELAY_URLS, - ...FAST_READ_RELAY_URLS, - ...PROFILE_RELAY_URLS, - ...menuRelayUrls - ]) - ) + operation: 'read', + applySocialKindBlockedFilter: false, + allowThirdPartyLocalRelays: true } - ], - { - operation: 'read', - applySocialKindBlockedFilter: false, - allowThirdPartyLocalRelays: true - } + ) ) } diff --git a/src/constants.ts b/src/constants.ts index 7cbf4944..62c84d2e 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -429,6 +429,12 @@ export const READ_ONLY_RELAY_URLS = [ 'wss://primus.nostr1.com' ] +/** + * Subset of {@link READ_ONLY_RELAY_URLS} that must also appear on the viewer's NIP-65 / favorites / 10432 + * before read or NIP-42 AUTH (unauthorized otherwise). Does not include aggr.nostr.land or search indexers. + */ +export const READ_ONLY_PERSONAL_LIST_REQUIRED_RELAY_URLS = ['wss://filter.nostr.wine'] as const + /** * Relays that need NIP-42 signed before the first REQ returns useful data. Same pool treatment as * {@link READ_ONLY_RELAY_URLS} (longer connect timeout + proactive `automaticallyAuth`), but **not** diff --git a/src/lib/citation-picker-relays.ts b/src/lib/citation-picker-relays.ts index 5689b65f..ceac04be 100644 --- a/src/lib/citation-picker-relays.ts +++ b/src/lib/citation-picker-relays.ts @@ -6,6 +6,7 @@ import { SEARCHABLE_RELAY_URLS } from '@/constants' import { mergeRelayUrlLayers } from '@/lib/favorites-feed-relays' +import { filterReadOnlyRelaysUnlessPersonal } from '@/lib/read-only-relay-personal' import { buildComprehensiveRelayList } from '@/lib/relay-list-builder' import { normalizeUrl } from '@/lib/url' import client from '@/services/client.service' @@ -63,5 +64,5 @@ export async function buildCitationPickerSearchRelayUrls(): Promise { [] ) - return merged.slice(0, CITATION_SEARCH_MAX_RELAYS) + return filterReadOnlyRelaysUnlessPersonal(merged).slice(0, CITATION_SEARCH_MAX_RELAYS) } diff --git a/src/lib/read-only-relay-personal.test.ts b/src/lib/read-only-relay-personal.test.ts new file mode 100644 index 00000000..e95d44c5 --- /dev/null +++ b/src/lib/read-only-relay-personal.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it, beforeEach } from 'vitest' +import { AGGR_NOSTR_LAND_WSS } from '@/lib/nostr-land-aggr' +import { + buildPersonalRelayKeySet, + filterReadOnlyRelaysUnlessPersonal, + isPersonalListRequiredReadOnlyRelay, + setViewerPersonalRelayKeys +} from './read-only-relay-personal' + +describe('read-only-relay-personal', () => { + beforeEach(() => { + setViewerPersonalRelayKeys(new Set()) + }) + + it('requires personal list only for filter.nostr.wine', () => { + expect(isPersonalListRequiredReadOnlyRelay('wss://filter.nostr.wine/')).toBe(true) + expect(isPersonalListRequiredReadOnlyRelay('wss://relay.damus.io/')).toBe(false) + expect(isPersonalListRequiredReadOnlyRelay(AGGR_NOSTR_LAND_WSS)).toBe(false) + expect(isPersonalListRequiredReadOnlyRelay('wss://search.nos.today/')).toBe(false) + }) + + it('strips unlisted filter.nostr.wine but keeps aggr and search indexers', () => { + const urls = [ + 'wss://relay.damus.io/', + 'wss://filter.nostr.wine/', + AGGR_NOSTR_LAND_WSS, + 'wss://search.nos.today/' + ] + expect(filterReadOnlyRelaysUnlessPersonal(urls)).toEqual([ + 'wss://relay.damus.io/', + AGGR_NOSTR_LAND_WSS, + 'wss://search.nos.today/' + ]) + }) + + it('keeps filter.nostr.wine when on the viewer personal list', () => { + setViewerPersonalRelayKeys(buildPersonalRelayKeySet(['wss://filter.nostr.wine/'])) + const urls = ['wss://relay.damus.io/', 'wss://filter.nostr.wine/'] + expect(filterReadOnlyRelaysUnlessPersonal(urls)).toEqual(urls) + }) +}) diff --git a/src/lib/read-only-relay-personal.ts b/src/lib/read-only-relay-personal.ts new file mode 100644 index 00000000..3ef14d3c --- /dev/null +++ b/src/lib/read-only-relay-personal.ts @@ -0,0 +1,67 @@ +import { READ_ONLY_PERSONAL_LIST_REQUIRED_RELAY_URLS } from '@/constants' +import { normalizeAnyRelayUrl } from '@/lib/url' + +const personalListRequiredKeySet = new Set( + READ_ONLY_PERSONAL_LIST_REQUIRED_RELAY_URLS.map((u) => + (normalizeAnyRelayUrl(u) || u).toLowerCase() + ).filter(Boolean) +) + +let viewerPersonalRelayKeys = new Set() + +export function relayUrlKey(url: string): string { + return (normalizeAnyRelayUrl(url) || url.trim()).toLowerCase() +} + +/** True when the relay must be on the viewer's personal lists before connect / AUTH. */ +export function isPersonalListRequiredReadOnlyRelay(url: string): boolean { + const key = relayUrlKey(url) + return key.length > 0 && personalListRequiredKeySet.has(key) +} + +/** @deprecated Use {@link isPersonalListRequiredReadOnlyRelay}; kept for NIP-42 call sites. */ +export function isReadOnlyIndexerRelay(url: string): boolean { + return isPersonalListRequiredReadOnlyRelay(url) +} + +export function buildPersonalRelayKeySet(urls: readonly string[]): Set { + const out = new Set() + for (const u of urls) { + const key = relayUrlKey(u) + if (key) out.add(key) + } + return out +} + +/** Updated when the logged-in viewer's NIP-65 / favorites / cache relays hydrate. */ +export function setViewerPersonalRelayKeys(keys: ReadonlySet): void { + viewerPersonalRelayKeys = new Set(keys) +} + +export function getViewerPersonalRelayKeys(): ReadonlySet { + return viewerPersonalRelayKeys +} + +export function isReadOnlyRelayAllowedForViewer(url: string): boolean { + if (!isPersonalListRequiredReadOnlyRelay(url)) return true + const key = relayUrlKey(url) + return key.length > 0 && viewerPersonalRelayKeys.has(key) +} + +function isAllowedForKeys(url: string, personalKeys: ReadonlySet): boolean { + if (!isPersonalListRequiredReadOnlyRelay(url)) return true + const key = relayUrlKey(url) + return key.length > 0 && personalKeys.has(key) +} + +/** + * Drop {@link READ_ONLY_PERSONAL_LIST_REQUIRED_RELAY_URLS} unless the viewer listed them on NIP-65 / favorites / 10432. + * Other read-only index relays (aggr.nostr.land, search.nos.today, …) are unchanged. + */ +export function filterReadOnlyRelaysUnlessPersonal( + urls: readonly string[], + personalKeys?: ReadonlySet +): string[] { + const keys = personalKeys ?? viewerPersonalRelayKeys + return urls.filter((u) => isAllowedForKeys(u, keys)) +} diff --git a/src/lib/relay-list-builder.ts b/src/lib/relay-list-builder.ts index 388b6aa4..467efeb9 100644 --- a/src/lib/relay-list-builder.ts +++ b/src/lib/relay-list-builder.ts @@ -14,6 +14,7 @@ import { feedRelayPolicyUrls } from '@/features/feed/relay-policy' import { mergeRelayUrlLayers, userReadRelaysWithHttp } from '@/lib/favorites-feed-relays' import { urlIsNonLocalForRemoteViewer } from '@/lib/relay-list-sanitize' import { isHttpRelayUrl, normalizeAnyRelayUrl, normalizeUrl } from '@/lib/url' +import { buildPersonalRelayKeySet, filterReadOnlyRelaysUnlessPersonal } from '@/lib/read-only-relay-personal' import { getCacheRelayUrls } from './private-relays' import { defaultFavoriteRelaysForViewer, viewerUsesGlobalRelayDefaults } from '@/lib/viewer-relay-defaults' import client from '@/services/client.service' @@ -152,6 +153,11 @@ export async function buildComprehensiveRelayList(options: RelayListBuilderOptio const relayUrls = new Set() const httpRelayUrls: string[] = [] + /** NIP-65 / favorites / 10432 — read-only index relays are only kept when listed here. */ + const personalRelayUrls: string[] = [] + const trackPersonal = (url: string) => { + personalRelayUrls.push(url) + } const normalizedBlocked = new Set( (blockedRelays || []).map(url => { const normalized = normalizeUrl(url) || url @@ -261,20 +267,32 @@ export async function buildComprehensiveRelayList(options: RelayListBuilderOptio const userRelayList = viewerRelayListForShare ?? (await client.peekRelayListFromStorage(userPubkey)) const userRead = userReadRelaysWithHttp(userRelayList).slice(0, 10) const userWrite = [...(userRelayList.write || []).slice(0, 10)] - userRead.forEach(addRelay) - userWrite.forEach(addRelay) + userRead.forEach((u) => { + trackPersonal(u) + addRelay(u) + }) + userWrite.forEach((u) => { + trackPersonal(u) + addRelay(u) + }) // Include local relays from kind 10432 if (includeLocalRelays) { const localRelays = await getCacheRelayUrls(userPubkey) - localRelays.forEach(addRelay) + localRelays.forEach((u) => { + trackPersonal(u) + addRelay(u) + }) } // Include favorite relays (kind 10012) if requested if (includeFavoriteRelays) { try { const favoriteRelays = await client.fetchFavoriteRelays(userPubkey) - favoriteRelays.forEach(addRelay) + favoriteRelays.forEach((u) => { + trackPersonal(u) + addRelay(u) + }) } catch (error) { logger.warn('[RelayListBuilder] Failed to fetch user favorite relays', { error }) } @@ -286,18 +304,27 @@ export async function buildComprehensiveRelayList(options: RelayListBuilderOptio // Even if not including user's own relays, still include user's inboxes for reading try { const userRelayList = viewerRelayListForShare ?? (await client.peekRelayListFromStorage(userPubkey)) - ;(userRelayList.read ?? []).slice(0, 10).forEach(addRelay) + ;(userRelayList.read ?? []).slice(0, 10).forEach((u) => { + trackPersonal(u) + addRelay(u) + }) // Include local relays from kind 10432 if enabled if (includeLocalRelays) { const localRelays = await getCacheRelayUrls(userPubkey) - localRelays.forEach(addRelay) + localRelays.forEach((u) => { + trackPersonal(u) + addRelay(u) + }) } // Menu / feed “favorite relays” (kind 10012) — same list as the sidebar; not part of NIP-65 alone. if (includeFavoriteRelays) { try { const favoriteRelays = await client.fetchFavoriteRelays(userPubkey) - favoriteRelays.forEach(addRelay) + favoriteRelays.forEach((u) => { + trackPersonal(u) + addRelay(u) + }) } catch (error) { logger.warn('[RelayListBuilder] Failed to fetch user favorite relays', { error }) } @@ -339,12 +366,16 @@ export async function buildComprehensiveRelayList(options: RelayListBuilderOptio } const merged = Array.from(relayUrls) - const ws = feedRelayPolicyUrls([{ source: 'fallback', urls: merged }], { - operation: 'read', - blockedRelays, - applySocialKindBlockedFilter: false, - allowThirdPartyLocalRelays: true - }) + const personalKeys = userPubkey ? buildPersonalRelayKeySet(personalRelayUrls) : undefined + const ws = filterReadOnlyRelaysUnlessPersonal( + feedRelayPolicyUrls([{ source: 'fallback', urls: merged }], { + operation: 'read', + blockedRelays, + applySocialKindBlockedFilter: false, + allowThirdPartyLocalRelays: true + }), + personalKeys + ) if (httpRelayUrls.length === 0) return ws const seen = new Set(ws.map(relayKey)) const out = [...ws] diff --git a/src/providers/NostrProvider/index.tsx b/src/providers/NostrProvider/index.tsx index 9497895b..3fb8161c 100644 --- a/src/providers/NostrProvider/index.tsx +++ b/src/providers/NostrProvider/index.tsx @@ -1050,8 +1050,10 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { useEffect(() => { if (account) { client.pubkey = account.pubkey + void client.syncViewerPersonalRelayKeys(account.pubkey) } else { client.pubkey = undefined + void client.syncViewerPersonalRelayKeys() } }, [account]) diff --git a/src/services/client-query.service.ts b/src/services/client-query.service.ts index 4aa83a01..ff5c50cd 100644 --- a/src/services/client-query.service.ts +++ b/src/services/client-query.service.ts @@ -34,6 +34,7 @@ import { patchRelayNoticeForFetchFailures } from '@/services/relay-notice-fetch- import type { Filter, Event as NEvent } from 'nostr-tools' import { SimplePool, EventTemplate, VerifiedEvent, nip19 } from 'nostr-tools' import type { AbstractRelay } from 'nostr-tools/abstract-relay' +import { filterReadOnlyRelaysUnlessPersonal } from '@/lib/read-only-relay-personal' import nip66Service from './nip66.service' import type { ISigner, TSignerType } from '@/types' @@ -400,11 +401,12 @@ export class QueryService { * Core query method with race-based fetching strategies */ async query( - urls: string[], - filter: Filter | Filter[], + urls: string[], + filter: Filter | Filter[], onevent?: (evt: NEvent) => void, options?: QueryOptions ): Promise { + urls = filterReadOnlyRelaysUnlessPersonal(urls) const sanitizedFilters = sanitizeFiltersBeforeReq(filter) if (sanitizedFilters.length === 0) return [] if (options?.signal?.aborted) return [] @@ -781,7 +783,7 @@ export class QueryService { return { close: () => {} } } const originalDedupedRelays = Array.from(new Set(urls)) - let relays = originalDedupedRelays + let relays = filterReadOnlyRelaysUnlessPersonal(originalDedupedRelays) const stripSocialBlockedRelays = SOCIAL_KIND_BLOCKED_RELAY_URLS.length > 0 && diff --git a/src/services/client.service.ts b/src/services/client.service.ts index d33f973d..cf4c6650 100644 --- a/src/services/client.service.ts +++ b/src/services/client.service.ts @@ -37,6 +37,13 @@ import { } from '@/constants' import { getCacheRelayUrls } from '@/lib/private-relays' +import { + buildPersonalRelayKeySet, + filterReadOnlyRelaysUnlessPersonal, + isReadOnlyIndexerRelay, + isReadOnlyRelayAllowedForViewer, + setViewerPersonalRelayKeys +} from '@/lib/read-only-relay-personal' import { profileFetchRelayUrlsWithoutFastReadLayer, viewerUsesGlobalRelayDefaults } from '@/lib/viewer-relay-defaults' /** NIP-01 filter keys only; NIP-50 adds `search` which non-searchable relays reject. */ @@ -569,6 +576,8 @@ class ClientService extends EventTarget { this.pool.automaticallyAuth = (relayURL: string) => { const n = normalizeUrl(relayURL) || relayURL if (!READ_ONLY_RELAY_CONNECT_BOOST_URLS.has(n)) return null + // Read-only index relays (e.g. filter.nostr.wine): only AUTH when on the viewer's relay lists. + if (isReadOnlyIndexerRelay(n) && !isReadOnlyRelayAllowedForViewer(n)) return null return async (event: EventTemplate) => { const evt = await queueRelayAuthSign(() => signer.signEvent(event)) return evt as VerifiedEvent @@ -579,6 +588,41 @@ class ClientService extends EventTarget { } } + /** + * NIP-65 read/write + favorites (10012) + cache relays (10432) for the logged-in viewer. + * Used to gate {@link READ_ONLY_RELAY_URLS} and proactive NIP-42 on those relays. + */ + async syncViewerPersonalRelayKeys(pubkey?: string): Promise { + const pk = pubkey?.trim() || this.pubkey?.trim() + if (!pk) { + setViewerPersonalRelayKeys(new Set()) + return + } + const urls: string[] = [] + try { + const rl = await this.peekRelayListFromStorage(pk) + urls.push( + ...(rl.read ?? []), + ...(rl.write ?? []), + ...(rl.httpRead ?? []), + ...(rl.httpWrite ?? []) + ) + } catch { + // ignore + } + try { + urls.push(...(await this.fetchFavoriteRelays(pk))) + } catch { + // ignore + } + try { + urls.push(...(await getCacheRelayUrls(pk))) + } catch { + // ignore + } + setViewerPersonalRelayKeys(buildPersonalRelayKeySet(urls)) + } + /** NIP-66: fetch relay discovery events (30166) in background to supplement search/NIP support. */ private async fetchNip66RelayDiscovery(): Promise { try { @@ -2476,7 +2520,9 @@ class ClientService extends EventTarget { relayReqLog?: { groupId?: string; onBatchEnd?: (rows: RelayOpTerminalRow[]) => void } ) { const originalDedupedRelays = Array.from(new Set(urls)) - let relays = originalDedupedRelays.filter((url) => !isHttpRelayUrl(url)) + let relays = filterReadOnlyRelaysUnlessPersonal( + originalDedupedRelays.filter((url) => !isHttpRelayUrl(url)) + ) if (navigator.onLine) { relays = stripLocalNetworkRelaysForWssReq(relays) } else { @@ -3336,10 +3382,10 @@ class ClientService extends EventTarget { // Delegate to QueryService private async query( - urls: string[], - filter: Filter | Filter[], + urls: string[], + filter: Filter | Filter[], onevent?: (evt: NEvent) => void, - options?: { + options?: { eoseTimeout?: number globalTimeout?: number /** For replaceable events: race strategy - wait 2s after first result, then return best */ @@ -3349,7 +3395,7 @@ class ClientService extends EventTarget { firstRelayResultGraceMs?: number | false } ) { - return this.queryService.query(urls, filter, onevent, options) + return this.queryService.query(filterReadOnlyRelaysUnlessPersonal(urls), filter, onevent, options) } // Legacy query implementation removed - now delegated to QueryService @@ -3384,7 +3430,9 @@ class ClientService extends EventTarget { .filter(Boolean) ) ) - const wsOriginal = originalDedupedRelays.filter((url) => !isHttpRelayUrl(url)) + const wsOriginal = filterReadOnlyRelaysUnlessPersonal( + originalDedupedRelays.filter((url) => !isHttpRelayUrl(url)) + ) let relays = [...wsOriginal] if (relays.length === 0 && httpRelayBases.length === 0) { relays = [...FAST_READ_RELAY_URLS] @@ -4208,6 +4256,9 @@ class ClientService extends EventTarget { const requestPromise = (async () => { try { const [relayList] = await this.fetchRelayLists([pubkey]) + if (this.pubkey && hexPubkeysEqual(this.pubkey, pubkey)) { + void this.syncViewerPersonalRelayKeys(pubkey) + } return relayList } catch (error) { logger.warn('[FetchRelayList] Fetch failed; using IndexedDB / defaults', {