Browse Source

tighten relay selection

imwald
Silberengel 2 weeks ago
parent
commit
4d5bc73cc4
  1. 5
      src/components/MetadataRelaysOnlySetting/index.tsx
  2. 4
      src/components/NormalFeed/index.tsx
  3. 39
      src/components/NoteList/index.tsx
  4. 34
      src/components/Relay/index.tsx
  5. 5
      src/constants.ts
  6. 1
      src/hooks/index.tsx
  7. 19
      src/hooks/useRelayPageFeedPolicy.ts
  8. 4
      src/lib/account-list-relay-urls.ts
  9. 12
      src/lib/favorites-feed-relays.ts
  10. 7
      src/lib/home-feed-relays.ts
  11. 4
      src/lib/live-activities.ts
  12. 14
      src/lib/metadata-policy-curated-relays.test.ts
  13. 34
      src/lib/metadata-policy-curated-relays.ts
  14. 62
      src/lib/read-only-relay-personal.test.ts
  15. 124
      src/lib/read-only-relay-personal.ts
  16. 6
      src/lib/relay-list-builder.ts
  17. 7
      src/lib/relay-strikes.test.ts
  18. 35
      src/lib/relay-strikes.ts
  19. 25
      src/lib/single-relay-browse-kinds.test.ts
  20. 19
      src/lib/single-relay-browse-kinds.ts
  21. 17
      src/providers/FeedProvider.test.ts
  22. 7
      src/providers/FeedProvider.tsx
  23. 16
      src/services/client-query.service.ts
  24. 92
      src/services/client.service.ts
  25. 6
      src/services/local-storage.service.ts

5
src/components/MetadataRelaysOnlySetting/index.tsx

@ -1,8 +1,8 @@
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch' import { Switch } from '@/components/ui/switch'
import { METADATA_RELAYS_ONLY_POLICY_CHANGED_EVENT, setRestrictConnectionsToMetadataRelaysOnly } from '@/lib/read-only-relay-personal'
import client from '@/services/client.service' import client from '@/services/client.service'
import storage from '@/services/local-storage.service' import storage from '@/services/local-storage.service'
import { setRestrictConnectionsToMetadataRelaysOnly } from '@/lib/read-only-relay-personal'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@ -22,6 +22,7 @@ export default function MetadataRelaysOnlySetting() {
setRestrictConnectionsToMetadataRelaysOnly(checked) setRestrictConnectionsToMetadataRelaysOnly(checked)
client.interruptBackgroundQueries({ closePooledRelayConnections: true }) client.interruptBackgroundQueries({ closePooledRelayConnections: true })
client.closeMetadataPolicyDisallowedRelayConnections() client.closeMetadataPolicyDisallowedRelayConnections()
window.dispatchEvent(new CustomEvent(METADATA_RELAYS_ONLY_POLICY_CHANGED_EVENT))
} }
return ( return (
@ -32,7 +33,7 @@ export default function MetadataRelaysOnlySetting() {
</div> </div>
<div className="text-muted-foreground text-xs max-w-xl"> <div className="text-muted-foreground text-xs max-w-xl">
{t( {t(
'When on, the app only opens read connections to relays on your Read & Write, Favorite, Cache, and HTTP relay lists. Publishing is unchanged. Relay explore and Search pages are exempt.' 'When on (default), the app only opens read connections to relays on your Read & Write, Favorite, Cache, and HTTP relay lists, plus hard-coded relays only while a query or subscription needs them. Publishing is unchanged. Relay explore and Search pages are exempt.'
)} )}
</div> </div>
</div> </div>

4
src/components/NormalFeed/index.tsx

@ -100,6 +100,8 @@ const NormalFeed = forwardRef<TNoteListRef, {
hostPrimaryPageName?: TPrimaryPageName hostPrimaryPageName?: TPrimaryPageName
/** Single-relay kindless wave EOSEd with no events: parent re-subscribes with explicit kinds. */ /** Single-relay kindless wave EOSEd with no events: parent re-subscribes with explicit kinds. */
onSingleRelayKindlessEmpty?: () => void onSingleRelayKindlessEmpty?: () => void
/** Relay explore: explicit kinds EOSEd empty — parent widens to kindless `{ limit }` once. */
onSingleRelayBrowseEmpty?: () => void
/** Shown above the feed list (e.g. after kindless→kinds fallback on a single-relay chip). */ /** Shown above the feed list (e.g. after kindless→kinds fallback on a single-relay chip). */
feedTopNotice?: ReactNode feedTopNotice?: ReactNode
/** Passed through to {@link NoteList} (d-tag browse one-shot). */ /** Passed through to {@link NoteList} (d-tag browse one-shot). */
@ -151,6 +153,7 @@ const NormalFeed = forwardRef<TNoteListRef, {
showFeedClientFilter: showFeedClientFilterProp, showFeedClientFilter: showFeedClientFilterProp,
hostPrimaryPageName, hostPrimaryPageName,
onSingleRelayKindlessEmpty, onSingleRelayKindlessEmpty,
onSingleRelayBrowseEmpty,
feedTopNotice, feedTopNotice,
oneShotFetch = false, oneShotFetch = false,
progressiveWarmupQuery, progressiveWarmupQuery,
@ -412,6 +415,7 @@ const NormalFeed = forwardRef<TNoteListRef, {
hostPrimaryPageName={hostPrimaryPageName} hostPrimaryPageName={hostPrimaryPageName}
feedClientFilterTabRowHost={mergeFilterWithTabsRow ? feedFilterTabRowHost : undefined} feedClientFilterTabRowHost={mergeFilterWithTabsRow ? feedFilterTabRowHost : undefined}
onSingleRelayKindlessEmpty={onSingleRelayKindlessEmpty} onSingleRelayKindlessEmpty={onSingleRelayKindlessEmpty}
onSingleRelayBrowseEmpty={onSingleRelayBrowseEmpty}
feedTopNotice={feedTopNotice} feedTopNotice={feedTopNotice}
oneShotFetch={oneShotFetch} oneShotFetch={oneShotFetch}
progressiveWarmupQuery={progressiveWarmupQuery} progressiveWarmupQuery={progressiveWarmupQuery}

39
src/components/NoteList/index.tsx

@ -27,6 +27,7 @@ import {
isSpellSubRequestsSameFiltersDifferentRelays isSpellSubRequestsSameFiltersDifferentRelays
} from '@/lib/spell-feed-request-identity' } from '@/lib/spell-feed-request-identity'
import logger from '@/lib/logger' import logger from '@/lib/logger'
import { isMetadataRelaysOnlyPolicyActive } from '@/lib/read-only-relay-personal'
import { eventSeenOnMatchesAllowlist } from '@/lib/relay-allowlist' import { eventSeenOnMatchesAllowlist } from '@/lib/relay-allowlist'
import { uniqueRelayUrlsFromSubRequests } from '@/lib/feed-relay-urls' import { uniqueRelayUrlsFromSubRequests } from '@/lib/feed-relay-urls'
import { isLocalNetworkUrl, normalizeAnyRelayUrl, normalizeUrl } from '@/lib/url' import { isLocalNetworkUrl, normalizeAnyRelayUrl, normalizeUrl } from '@/lib/url'
@ -781,6 +782,7 @@ const NoteList = forwardRef(
*/ */
feedClientFilterTabRowHost, feedClientFilterTabRowHost,
onSingleRelayKindlessEmpty, onSingleRelayKindlessEmpty,
onSingleRelayBrowseEmpty,
feedTopNotice, feedTopNotice,
gridLayout = false, gridLayout = false,
/** /**
@ -855,6 +857,8 @@ const NoteList = forwardRef(
feedClientFilterTabRowHost?: HTMLElement | null feedClientFilterTabRowHost?: HTMLElement | null
/** Single-relay kindless: if EOSE with no events, parent switches to explicit kinds in `subRequests`. */ /** Single-relay kindless: if EOSE with no events, parent switches to explicit kinds in `subRequests`. */
onSingleRelayKindlessEmpty?: () => void onSingleRelayKindlessEmpty?: () => void
/** Relay explore: explicit kinds EOSE empty — parent retries kindless `{ limit }` once. */
onSingleRelayBrowseEmpty?: () => void
/** Optional banner above the feed (e.g. kindless→kinds fallback). */ /** Optional banner above the feed (e.g. kindless→kinds fallback). */
feedTopNotice?: ReactNode feedTopNotice?: ReactNode
/** When true, render events as an Instagram-style 3-column square media grid. */ /** When true, render events as an Instagram-style 3-column square media grid. */
@ -946,8 +950,11 @@ const NoteList = forwardRef(
const feedRelayReturnedAnyEventRef = useRef(false) const feedRelayReturnedAnyEventRef = useRef(false)
/** One-shot per timeline init: avoid double-calling parent fallback (Strict Mode / duplicate EOSE). */ /** One-shot per timeline init: avoid double-calling parent fallback (Strict Mode / duplicate EOSE). */
const singleRelayKindlessFallbackAttemptedRef = useRef(false) const singleRelayKindlessFallbackAttemptedRef = useRef(false)
const singleRelayBrowseFallbackAttemptedRef = useRef(false)
const onSingleRelayKindlessEmptyRef = useRef(onSingleRelayKindlessEmpty) const onSingleRelayKindlessEmptyRef = useRef(onSingleRelayKindlessEmpty)
onSingleRelayKindlessEmptyRef.current = onSingleRelayKindlessEmpty onSingleRelayKindlessEmptyRef.current = onSingleRelayKindlessEmpty
const onSingleRelayBrowseEmptyRef = useRef(onSingleRelayBrowseEmpty)
onSingleRelayBrowseEmptyRef.current = onSingleRelayBrowseEmpty
/** Timeout handle for kindless EOSE fallback; cleared when EOSE arrives or effect tears down. */ /** Timeout handle for kindless EOSE fallback; cleared when EOSE arrives or effect tears down. */
const kindlessEoseTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null) const kindlessEoseTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
/** Dedupe {@link toast.error} when relays return nothing for a feed load. */ /** Dedupe {@link toast.error} when relays return nothing for a feed load. */
@ -2250,6 +2257,7 @@ const NoteList = forwardRef(
feedPaintLiveRelayDoneRef.current = false feedPaintLiveRelayDoneRef.current = false
feedRelayReturnedAnyEventRef.current = false feedRelayReturnedAnyEventRef.current = false
singleRelayKindlessFallbackAttemptedRef.current = false singleRelayKindlessFallbackAttemptedRef.current = false
singleRelayBrowseFallbackAttemptedRef.current = false
} }
// Re-subscribe with rows visible (e.g. relay URL expansion): don't flash global loading / skeleton. // Re-subscribe with rows visible (e.g. relay URL expansion): don't flash global loading / skeleton.
@ -2293,6 +2301,14 @@ const NoteList = forwardRef(
if (useFilterAsIs && allowKindlessRelayExplore && urls.length === 1) { if (useFilterAsIs && allowKindlessRelayExplore && urls.length === 1) {
return false return false
} }
if (
useFilterAsIs &&
urls.length === 1 &&
relayAuthoritativeFeedOnlyRef.current &&
hostPrimaryPageNameRef.current === 'relay'
) {
return false
}
return true return true
}) })
if (invalidFilters.length > 0) { if (invalidFilters.length > 0) {
@ -3401,6 +3417,28 @@ const NoteList = forwardRef(
} }
} }
// Relay explore: explicit kinds returned nothing — parent retries kindless once.
if (
eosed &&
effectActive &&
onSingleRelayBrowseEmptyRef.current &&
!singleRelayBrowseFallbackAttemptedRef.current &&
!feedRelayReturnedAnyEventRef.current &&
relayAuthoritativeFeedOnlyRef.current &&
hostPrimaryPageNameRef.current === 'relay'
) {
const reqs = subRequestsRef.current
const f0 = reqs[0]
if (reqs.length === 1 && f0 && f0.urls.length === 1) {
const f = f0.filter as Filter
const hasKinds = Array.isArray(f.kinds) && f.kinds.length > 0
if (hasKinds) {
singleRelayBrowseFallbackAttemptedRef.current = true
onSingleRelayBrowseEmptyRef.current()
}
}
}
if ( if (
effectActive && effectActive &&
eosed && eosed &&
@ -3960,6 +3998,7 @@ const NoteList = forwardRef(
useEffect(() => { useEffect(() => {
if (relayAuthoritativeFeedOnly) return if (relayAuthoritativeFeedOnly) return
if (!timelinePublicReadFallback) return if (!timelinePublicReadFallback) return
if (isMetadataRelaysOnlyPolicyActive()) return
if (feedSubscriptionKey === 'home-all-favorites') return if (feedSubscriptionKey === 'home-all-favorites') return
if (oneShotFetch || areAlgoRelays) return if (oneShotFetch || areAlgoRelays) return
if (!navigator.onLine) return if (!navigator.onLine) return

34
src/components/Relay/index.tsx

@ -2,7 +2,7 @@ import NormalFeed from '@/components/NormalFeed'
import type { TNoteListRef } from '@/components/NoteList' import type { TNoteListRef } from '@/components/NoteList'
import RelayInfo from '@/components/RelayInfo' import RelayInfo from '@/components/RelayInfo'
import SearchInput from '@/components/SearchInput' import SearchInput from '@/components/SearchInput'
import { useBypassMetadataRelaysOnlyPolicy, useFetchRelayInfo } from '@/hooks' import { useFetchRelayInfo, useRelayPageFeedPolicy } from '@/hooks'
import type { TPrimaryPageName } from '@/PageManager' import type { TPrimaryPageName } from '@/PageManager'
import { SINGLE_RELAY_KINDLESS_REQ_LIMIT } from '@/constants' import { SINGLE_RELAY_KINDLESS_REQ_LIMIT } from '@/constants'
import { canonicalRelaySessionKey, isLocalNetworkUrl, normalizeRelayUrlForPage } from '@/lib/url' import { canonicalRelaySessionKey, isLocalNetworkUrl, normalizeRelayUrlForPage } from '@/lib/url'
@ -15,6 +15,7 @@ import { forwardRef, useCallback, useEffect, useMemo, useRef, useState } from 'r
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { AlexandriaEventsSearchEmptyCta } from '@/components/AlexandriaEventsSearchEmptyCta' import { AlexandriaEventsSearchEmptyCta } from '@/components/AlexandriaEventsSearchEmptyCta'
import { buildAlexandriaEventsSearchUrlFromNotesQuery } from '@/lib/alexandria-events-search-url' import { buildAlexandriaEventsSearchUrlFromNotesQuery } from '@/lib/alexandria-events-search-url'
import { kindsForSingleRelayBrowse } from '@/lib/single-relay-browse-kinds'
import { stableFeedKindKey } from '@/features/feed/descriptor' import { stableFeedKindKey } from '@/features/feed/descriptor'
import NotFound from '../NotFound' import NotFound from '../NotFound'
@ -32,13 +33,15 @@ const Relay = forwardRef<
ref ref
) { ) {
const { t } = useTranslation() const { t } = useTranslation()
useBypassMetadataRelaysOnlyPolicy() useRelayPageFeedPolicy()
const { addRelayUrls, removeRelayUrls } = useCurrentRelays() const { addRelayUrls, removeRelayUrls } = useCurrentRelays()
const { showKinds } = useKindFilterOrDefaults() const { showKinds } = useKindFilterOrDefaults()
const normalizedUrl = useMemo(() => (url ? normalizeRelayUrlForPage(url) : undefined), [url]) const normalizedUrl = useMemo(() => (url ? normalizeRelayUrlForPage(url) : undefined), [url])
const { relayInfo } = useFetchRelayInfo(normalizedUrl) const { relayInfo } = useFetchRelayInfo(normalizedUrl)
const [searchInput, setSearchInput] = useState('') const [searchInput, setSearchInput] = useState('')
const [debouncedInput, setDebouncedInput] = useState(searchInput) const [debouncedInput, setDebouncedInput] = useState(searchInput)
/** After explicit-kinds REQ EOSEs empty, retry kindless `{ limit }` once (document/specialty relays). */
const [kindlessBrowseFallback, setKindlessBrowseFallback] = useState(false)
const internalNoteListRef = useRef<TNoteListRef>(null) const internalNoteListRef = useRef<TNoteListRef>(null)
const noteListRef = ref ?? internalNoteListRef const noteListRef = ref ?? internalNoteListRef
@ -81,13 +84,21 @@ const Relay = forwardRef<
} }
}, [normalizedUrl, noteListRef]) }, [normalizedUrl, noteListRef])
useEffect(() => {
setKindlessBrowseFallback(false)
}, [normalizedUrl])
/** Default browse: explicit kinds (many strfry / small relays never return a useful kindless global REQ). */ /** Default browse: explicit kinds (many strfry / small relays never return a useful kindless global REQ). */
const relayBrowseKindsKey = useMemo(() => stableFeedKindKey(showKinds), [showKinds]) const relayBrowseKindsKey = useMemo(() => stableFeedKindKey(showKinds), [showKinds])
const relayBrowseKinds = useMemo( const relayBrowseKinds = useMemo(
() => (showKinds.length > 0 ? showKinds : [kinds.ShortTextNote]), () => (normalizedUrl ? kindsForSingleRelayBrowse(normalizedUrl, showKinds) : [kinds.ShortTextNote]),
[relayBrowseKindsKey, showKinds] [relayBrowseKindsKey, showKinds, normalizedUrl]
) )
const onSingleRelayBrowseEmpty = useCallback(() => {
setKindlessBrowseFallback(true)
}, [])
const relayFeedSubRequests = useMemo<TFeedSubRequest[]>(() => { const relayFeedSubRequests = useMemo<TFeedSubRequest[]>(() => {
if (!normalizedUrl) return [] if (!normalizedUrl) return []
const q = debouncedInput.trim() const q = debouncedInput.trim()
@ -99,13 +110,21 @@ const Relay = forwardRef<
} }
] ]
} }
if (kindlessBrowseFallback) {
return [
{
urls: [normalizedUrl],
filter: { limit: SINGLE_RELAY_KINDLESS_REQ_LIMIT }
}
]
}
return [ return [
{ {
urls: [normalizedUrl], urls: [normalizedUrl],
filter: { kinds: [...relayBrowseKinds], limit: SINGLE_RELAY_KINDLESS_REQ_LIMIT } filter: { kinds: [...relayBrowseKinds], limit: SINGLE_RELAY_KINDLESS_REQ_LIMIT }
} }
] ]
}, [normalizedUrl, debouncedInput, relayBrowseKindsKey]) }, [normalizedUrl, debouncedInput, relayBrowseKindsKey, kindlessBrowseFallback])
const allowKindlessRelayExplore = debouncedInput.trim().length > 0 const allowKindlessRelayExplore = debouncedInput.trim().length > 0
@ -116,7 +135,7 @@ const Relay = forwardRef<
) )
const shouldHideEventNotFromThisRelay = useCallback( const shouldHideEventNotFromThisRelay = useCallback(
(ev: Event) => { (ev: Event) => {
if (hostPrimaryPageName === 'relay' || allowKindlessRelayExplore) { if (allowKindlessRelayExplore) {
return false return false
} }
if (!relaySeenMatchKey) return false if (!relaySeenMatchKey) return false
@ -127,7 +146,7 @@ const Relay = forwardRef<
if (seen.length === 0) return false if (seen.length === 0) return false
return !seen.some((u) => canonicalRelaySessionKey(u) === relaySeenMatchKey) return !seen.some((u) => canonicalRelaySessionKey(u) === relaySeenMatchKey)
}, },
[relaySeenMatchKey, normalizedUrl, hostPrimaryPageName, allowKindlessRelayExplore] [relaySeenMatchKey, normalizedUrl, allowKindlessRelayExplore]
) )
const alexandriaFeedEmptyUrl = useMemo(() => { const alexandriaFeedEmptyUrl = useMemo(() => {
@ -168,6 +187,7 @@ const Relay = forwardRef<
extraShouldHideEvent={shouldHideEventNotFromThisRelay} extraShouldHideEvent={shouldHideEventNotFromThisRelay}
extraShouldHideRepliesEvent={shouldHideEventNotFromThisRelay} extraShouldHideRepliesEvent={shouldHideEventNotFromThisRelay}
relayAuthoritativeFeedOnly relayAuthoritativeFeedOnly
onSingleRelayBrowseEmpty={onSingleRelayBrowseEmpty}
alexandriaEmptyUrl={alexandriaFeedEmptyUrl} alexandriaEmptyUrl={alexandriaFeedEmptyUrl}
/> />
</div> </div>

5
src/constants.ts

@ -165,7 +165,7 @@ export const RELAY_SLOW_PARK_SIGNALS_THRESHOLD = 2
export const RELAY_SLOW_PARK_COOLDOWN_MS = 5 * 60 * 1000 export const RELAY_SLOW_PARK_COOLDOWN_MS = 5 * 60 * 1000
/** Close pooled WebSocket when no SUBs and no pool activity for this long (see {@link initRelayPoolIdle}). */ /** Close pooled WebSocket when no SUBs and no pool activity for this long (see {@link initRelayPoolIdle}). */
export const RELAY_POOL_SOCKET_IDLE_MS = 90_000 export const RELAY_POOL_SOCKET_IDLE_MS = 15_000
/** How often to scan for idle relay sockets. */ /** How often to scan for idle relay sockets. */
export const RELAY_POOL_IDLE_SWEEP_INTERVAL_MS = 45_000 export const RELAY_POOL_IDLE_SWEEP_INTERVAL_MS = 45_000
@ -510,7 +510,8 @@ export const FAST_WRITE_RELAY_URLS = [
'wss://relay.damus.io', 'wss://relay.damus.io',
'wss://relay.primal.net', 'wss://relay.primal.net',
'wss://thecitadel.nostr1.com', 'wss://thecitadel.nostr1.com',
'wss://nos.lol' 'wss://nos.lol',
'wss://relay.layer.systems'
] ]
/** /**

1
src/hooks/index.tsx

@ -1,4 +1,5 @@
export * from './useBypassMetadataRelaysOnlyPolicy' export * from './useBypassMetadataRelaysOnlyPolicy'
export * from './useRelayPageFeedPolicy'
export * from './useNearViewport' export * from './useNearViewport'
export * from './useFetchCalendarRsvps' export * from './useFetchCalendarRsvps'
export * from './useFetchEvent' export * from './useFetchEvent'

19
src/hooks/useRelayPageFeedPolicy.ts

@ -0,0 +1,19 @@
import {
enterMetadataRelaysOnlyBypass,
enterSingleRelayExplicitBrowse,
leaveMetadataRelaysOnlyBypass,
leaveSingleRelayExplicitBrowse
} from '@/lib/read-only-relay-personal'
import { useEffect } from 'react'
/** Relay detail feed: bypass metadata-only narrowing, user blocks, and session strikes for the page relay. */
export function useRelayPageFeedPolicy(): void {
useEffect(() => {
enterMetadataRelaysOnlyBypass()
enterSingleRelayExplicitBrowse()
return () => {
leaveSingleRelayExplicitBrowse()
leaveMetadataRelaysOnlyBypass()
}
}, [])
}

4
src/lib/account-list-relay-urls.ts

@ -32,7 +32,7 @@ export async function buildAccountListRelayUrlsForMerge(options: {
blockedRelays, blockedRelays,
maxRelays: 100, maxRelays: 100,
applySocialKindBlockedFilter: false, applySocialKindBlockedFilter: false,
includeGlobalFastRead: useGlobal includeGlobalFastRead: useGlobal && viewerIncludeGlobalFastReadRelayLayer()
}) })
const write = buildPrioritizedWriteRelayUrls({ const write = buildPrioritizedWriteRelayUrls({
userWriteRelays: writeOutboxes, userWriteRelays: writeOutboxes,
@ -40,7 +40,7 @@ export async function buildAccountListRelayUrlsForMerge(options: {
blockedRelays, blockedRelays,
maxRelays: 100, maxRelays: 100,
applySocialKindBlockedFilter: false, applySocialKindBlockedFilter: false,
includeGlobalFastWriteReadTails: useGlobal includeGlobalFastWriteReadTails: useGlobal && viewerIncludeGlobalFastWriteRelayLayer()
}) })
const merged = [...read, ...write] const merged = [...read, ...write]
return [...new Set(merged.map((u) => normalizeRelayUrlByScheme(u) || u).filter(Boolean))] return [...new Set(merged.map((u) => normalizeRelayUrlByScheme(u) || u).filter(Boolean))]

12
src/lib/favorites-feed-relays.ts

@ -20,6 +20,7 @@ import { feedRelayPolicyUrls, type FeedRelayLayer } from '@/features/feed/relay-
import { stripMailboxLocalUrlsForRemoteViewers } from '@/lib/relay-list-sanitize' import { stripMailboxLocalUrlsForRemoteViewers } from '@/lib/relay-list-sanitize'
import { relaySessionStrikes } from '@/lib/relay-strikes' import { relaySessionStrikes } from '@/lib/relay-strikes'
import { profileFetchRelayUrlsWithoutFastReadLayer } from '@/lib/viewer-relay-defaults' import { profileFetchRelayUrlsWithoutFastReadLayer } from '@/lib/viewer-relay-defaults'
import { viewerIncludeGlobalFastReadRelayLayer, viewerIncludeGlobalFastWriteRelayLayer } from '@/lib/read-only-relay-personal'
import { getCacheRelayUrlsFromEvent } from '@/lib/private-relays' import { getCacheRelayUrlsFromEvent } from '@/lib/private-relays'
import { collectUserReadInboxUrls } from '@/lib/viewer-read-inboxes' import { collectUserReadInboxUrls } from '@/lib/viewer-read-inboxes'
import { collectUserWriteOutboxUrls } from '@/lib/viewer-write-outboxes' import { collectUserWriteOutboxUrls } from '@/lib/viewer-write-outboxes'
@ -141,12 +142,13 @@ export function buildProfileAugmentedReadRelayUrls(
maxRelays: number = PROFILE_AUGMENTED_READ_MAX_RELAYS, maxRelays: number = PROFILE_AUGMENTED_READ_MAX_RELAYS,
useGlobalRelayBootstrap = true useGlobalRelayBootstrap = true
): string[] { ): string[] {
const allowFastReadBootstrap = useGlobalRelayBootstrap && viewerIncludeGlobalFastReadRelayLayer()
const fastReadLayer = const fastReadLayer =
useGlobalRelayBootstrap allowFastReadBootstrap
? (FAST_READ_RELAY_URLS.map((u) => normalizeUrl(u) || u).filter(Boolean) as string[]) ? (FAST_READ_RELAY_URLS.map((u) => normalizeUrl(u) || u).filter(Boolean) as string[])
: [] : []
const merged = mergeRelayUrlLayers( const merged = mergeRelayUrlLayers(
useGlobalRelayBootstrap ? [fastReadLayer, authorRelayUrls] : [authorRelayUrls, fastReadLayer], allowFastReadBootstrap ? [fastReadLayer, authorRelayUrls] : [authorRelayUrls, fastReadLayer],
blockedRelays blockedRelays
) )
return merged.slice(0, maxRelays) return merged.slice(0, maxRelays)
@ -196,7 +198,8 @@ export function getRelayUrlsWithFavoritesFastReadAndInbox(
options?: ReadRelayPriorityOptions options?: ReadRelayPriorityOptions
): string[] { ): string[] {
const useFavDefaults = options?.useGlobalFavoriteDefaults !== false const useFavDefaults = options?.useGlobalFavoriteDefaults !== false
const includeFast = options?.includeGlobalFastRead !== false const includeFast =
options?.includeGlobalFastRead !== false && viewerIncludeGlobalFastReadRelayLayer()
const favorites = getFavoritesFeedRelayUrls(favoriteRelays, blockedRelays, useFavDefaults) const favorites = getFavoritesFeedRelayUrls(favoriteRelays, blockedRelays, useFavDefaults)
return buildPrioritizedReadRelayUrls({ return buildPrioritizedReadRelayUrls({
userReadRelays: userInboxReadRelays, userReadRelays: userInboxReadRelays,
@ -315,7 +318,8 @@ export function augmentSubRequestsWithFavoritesFastReadAndInbox(
: relayFilterIncludesSocialKindBlockedKind(r.filter) : relayFilterIncludesSocialKindBlockedKind(r.filter)
const useFavDefaults = options?.useGlobalFavoriteDefaults !== false const useFavDefaults = options?.useGlobalFavoriteDefaults !== false
const includeFast = options?.includeGlobalFastRead !== false const includeFast =
options?.includeGlobalFastRead !== false && viewerIncludeGlobalFastReadRelayLayer()
const favorites = getFavoritesFeedRelayUrls(favoriteRelays, blockedRelays, useFavDefaults) const favorites = getFavoritesFeedRelayUrls(favoriteRelays, blockedRelays, useFavDefaults)
const authorOnly = dedupeNormalizeRelayUrlsOrdered(options?.authorWriteRelays ?? []) const authorOnly = dedupeNormalizeRelayUrlsOrdered(options?.authorWriteRelays ?? [])

7
src/lib/home-feed-relays.ts

@ -1,6 +1,8 @@
import { feedRelayPolicyUrls } from '@/features/feed/relay-policy' import { feedRelayPolicyUrls } from '@/features/feed/relay-policy'
import { getFavoritesFeedRelayUrls } from '@/lib/favorites-feed-relays' import { getFavoritesFeedRelayUrls } from '@/lib/favorites-feed-relays'
import { stripNostrLandAggrFromRelayUrls } from '@/lib/nostr-land-relay-eligibility' import { stripNostrLandAggrFromRelayUrls } from '@/lib/nostr-land-relay-eligibility'
import { isMetadataRelaysOnlyPolicyActive } from '@/lib/read-only-relay-personal'
import { isWispTrendingNotesRelayUrl } from '@/lib/wisp-trending-relay'
export { stripNostrLandAggrFromRelayUrls } export { stripNostrLandAggrFromRelayUrls }
@ -28,6 +30,9 @@ export function buildAllFavoritesFeedRelayUrls(
extraFeedRelayUrls: string[], extraFeedRelayUrls: string[],
useGlobalFavoriteDefaults = true useGlobalFavoriteDefaults = true
): string[] { ): string[] {
const extras = isMetadataRelaysOnlyPolicyActive()
? extraFeedRelayUrls.filter((u) => !isWispTrendingNotesRelayUrl(u))
: extraFeedRelayUrls
return stripNostrLandAggrFromRelayUrls( return stripNostrLandAggrFromRelayUrls(
feedRelayPolicyUrls( feedRelayPolicyUrls(
[ [
@ -35,7 +40,7 @@ export function buildAllFavoritesFeedRelayUrls(
source: 'favorites', source: 'favorites',
urls: getFavoritesFeedRelayUrls(favoriteRelays, blockedRelays, useGlobalFavoriteDefaults) urls: getFavoritesFeedRelayUrls(favoriteRelays, blockedRelays, useGlobalFavoriteDefaults)
}, },
{ source: 'fallback', urls: extraFeedRelayUrls } { source: 'fallback', urls: extras }
], ],
{ {
operation: 'favorites-feed', operation: 'favorites-feed',

4
src/lib/live-activities.ts

@ -7,6 +7,7 @@ import {
MAX_REQ_RELAY_URLS, MAX_REQ_RELAY_URLS,
relayUrlsLocalsFirst relayUrlsLocalsFirst
} from '@/lib/relay-url-priority' } from '@/lib/relay-url-priority'
import { viewerIncludeGlobalFastReadRelayLayer } from '@/lib/read-only-relay-personal'
import { normalizeAnyRelayUrl } from '@/lib/url' import { normalizeAnyRelayUrl } from '@/lib/url'
import { nip19, type Event, type Filter } from 'nostr-tools' import { nip19, type Event, type Filter } from 'nostr-tools'
@ -669,7 +670,8 @@ export function buildLiveActivitiesRelayUrls(options: {
includeGlobalFastRead?: boolean includeGlobalFastRead?: boolean
}): string[] { }): string[] {
const { loggedIn, favoriteRelays, blockedRelays, relayListRead, relayListWrite } = options const { loggedIn, favoriteRelays, blockedRelays, relayListRead, relayListWrite } = options
const includeFast = options.includeGlobalFastRead !== false const includeFast =
options.includeGlobalFastRead !== false && viewerIncludeGlobalFastReadRelayLayer()
const useGlobalFavoriteDefaults = includeFast const useGlobalFavoriteDefaults = includeFast
if (loggedIn) { if (loggedIn) {
const fav = relayUrlsLocalsFirst( const fav = relayUrlsLocalsFirst(

14
src/lib/metadata-policy-curated-relays.test.ts

@ -1,10 +1,20 @@
import { PROFILE_RELAY_URLS } from '@/constants' import { DOCUMENT_RELAY_URLS, FAST_READ_RELAY_URLS, PROFILE_RELAY_URLS } from '@/constants'
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { isMetadataPolicyCuratedRelay } from './metadata-policy-curated-relays' import {
isMetadataPolicyCuratedRelay,
isMetadataPolicyOperationScopedRelay
} from './metadata-policy-curated-relays'
describe('metadata-policy-curated-relays', () => { describe('metadata-policy-curated-relays', () => {
it('recognizes profile relay constants', () => { it('recognizes profile relay constants', () => {
expect(isMetadataPolicyCuratedRelay(PROFILE_RELAY_URLS[0]!)).toBe(true) expect(isMetadataPolicyCuratedRelay(PROFILE_RELAY_URLS[0]!)).toBe(true)
expect(isMetadataPolicyCuratedRelay('wss://nostr.wirednet.jp/')).toBe(false) expect(isMetadataPolicyCuratedRelay('wss://nostr.wirednet.jp/')).toBe(false)
}) })
it('operation scope excludes FAST_READ widening', () => {
expect(isMetadataPolicyOperationScopedRelay(DOCUMENT_RELAY_URLS[0]!)).toBe(true)
expect(isMetadataPolicyOperationScopedRelay(PROFILE_RELAY_URLS[0]!)).toBe(true)
expect(isMetadataPolicyOperationScopedRelay(FAST_READ_RELAY_URLS[0]!)).toBe(false)
expect(isMetadataPolicyOperationScopedRelay('wss://nostr.wirednet.jp/')).toBe(false)
})
}) })

34
src/lib/metadata-policy-curated-relays.ts

@ -24,7 +24,20 @@ const METADATA_POLICY_CURATED_RELAY_LISTS: readonly (readonly string[])[] = [
NIP42_POOL_AUTOMATIC_AUTH_RELAY_URLS NIP42_POOL_AUTOMATIC_AUTH_RELAY_URLS
] ]
/**
* Curated stacks allowed to connect briefly under metadata-only policy when merged into an active
* query/subscribe (documents, GIFs, profiles, ). Excludes FAST_READ, search indexers, and read-only mirrors.
*/
const METADATA_POLICY_OPERATION_SCOPED_RELAY_LISTS: readonly (readonly string[])[] = [
PROFILE_RELAY_URLS,
DOCUMENT_RELAY_URLS,
GIF_RELAY_URLS,
BOOKSTR_RELAY_URLS,
FOLLOWS_HISTORY_RELAY_URLS
]
let curatedRelayKeySet: ReadonlySet<string> | null = null let curatedRelayKeySet: ReadonlySet<string> | null = null
let operationScopedRelayKeySet: ReadonlySet<string> | null = null
function relayKeyForCuratedSet(url: string): string { function relayKeyForCuratedSet(url: string): string {
return (normalizeAnyRelayUrl(url) || url.trim()).toLowerCase() return (normalizeAnyRelayUrl(url) || url.trim()).toLowerCase()
@ -44,12 +57,32 @@ function getCuratedRelayKeySet(): ReadonlySet<string> {
return curatedRelayKeySet return curatedRelayKeySet
} }
function getOperationScopedRelayKeySet(): ReadonlySet<string> {
if (!operationScopedRelayKeySet) {
const out = new Set<string>()
for (const list of METADATA_POLICY_OPERATION_SCOPED_RELAY_LISTS) {
for (const u of list) {
const key = relayKeyForCuratedSet(u)
if (key) out.add(key)
}
}
operationScopedRelayKeySet = out
}
return operationScopedRelayKeySet
}
/** True for relays from specialized constants (profile fetch, read-only indexers, NIP-50, …). */ /** True for relays from specialized constants (profile fetch, read-only indexers, NIP-50, …). */
export function isMetadataPolicyCuratedRelay(url: string): boolean { export function isMetadataPolicyCuratedRelay(url: string): boolean {
const key = relayKeyForCuratedSet(url) const key = relayKeyForCuratedSet(url)
return key.length > 0 && getCuratedRelayKeySet().has(key) return key.length > 0 && getCuratedRelayKeySet().has(key)
} }
/** Purpose-specific constants that may connect during an in-flight read (not general feed widening). */
export function isMetadataPolicyOperationScopedRelay(url: string): boolean {
const key = relayKeyForCuratedSet(url)
return key.length > 0 && getOperationScopedRelayKeySet().has(key)
}
let profileRelayKeySet: ReadonlySet<string> | null = null let profileRelayKeySet: ReadonlySet<string> | null = null
function getProfileRelayKeySet(): ReadonlySet<string> { function getProfileRelayKeySet(): ReadonlySet<string> {
@ -73,5 +106,6 @@ export function isMetadataPolicyProfileRelay(url: string): boolean {
/** For tests: reset lazy-built key set after constant changes. */ /** For tests: reset lazy-built key set after constant changes. */
export function resetMetadataPolicyCuratedRelayKeysForTests(): void { export function resetMetadataPolicyCuratedRelayKeysForTests(): void {
curatedRelayKeySet = null curatedRelayKeySet = null
operationScopedRelayKeySet = null
profileRelayKeySet = null profileRelayKeySet = null
} }

62
src/lib/read-only-relay-personal.test.ts

@ -4,11 +4,16 @@ import { syncViewerRelayStackNostrLandAggrEligible } from '@/lib/nostr-land-rela
import { import {
buildPersonalRelayKeySet, buildPersonalRelayKeySet,
filterReadOnlyRelaysUnlessPersonal, filterReadOnlyRelaysUnlessPersonal,
grantRelayConnectionOperationScope,
isPersonalListRequiredReadOnlyRelay, isPersonalListRequiredReadOnlyRelay,
isRelayConnectionAllowedForViewer, isRelayConnectionAllowedForViewer,
resetRelayConnectionOperationScopeForTests,
sanitizeRelayUrlsForFetch, sanitizeRelayUrlsForFetch,
enterMetadataRelaysOnlyBypass, enterMetadataRelaysOnlyBypass,
enterSingleRelayExplicitBrowse,
enterSingleRelayExplicitFetchScope,
leaveMetadataRelaysOnlyBypass, leaveMetadataRelaysOnlyBypass,
leaveSingleRelayExplicitBrowse,
setRestrictConnectionsToMetadataRelaysOnly, setRestrictConnectionsToMetadataRelaysOnly,
setViewerPersonalRelayKeys setViewerPersonalRelayKeys
} from './read-only-relay-personal' } from './read-only-relay-personal'
@ -20,13 +25,16 @@ describe('read-only-relay-personal', () => {
setViewerPersonalRelayKeys(new Set(), { viewerActive: false }) setViewerPersonalRelayKeys(new Set(), { viewerActive: false })
setViewerBlockedRelayUrls([]) setViewerBlockedRelayUrls([])
syncViewerRelayStackNostrLandAggrEligible([]) syncViewerRelayStackNostrLandAggrEligible([])
resetRelayConnectionOperationScopeForTests()
}) })
afterEach(() => { afterEach(() => {
setRestrictConnectionsToMetadataRelaysOnly(false) setRestrictConnectionsToMetadataRelaysOnly(false)
leaveMetadataRelaysOnlyBypass() leaveMetadataRelaysOnlyBypass()
leaveSingleRelayExplicitBrowse()
setViewerBlockedRelayUrls([]) setViewerBlockedRelayUrls([])
syncViewerRelayStackNostrLandAggrEligible([]) syncViewerRelayStackNostrLandAggrEligible([])
resetRelayConnectionOperationScopeForTests()
}) })
it('requires personal list only for filter.nostr.wine', () => { it('requires personal list only for filter.nostr.wine', () => {
@ -73,7 +81,7 @@ describe('read-only-relay-personal', () => {
expect(filterReadOnlyRelaysUnlessPersonal(urls)).toEqual(urls) expect(filterReadOnlyRelaysUnlessPersonal(urls)).toEqual(urls)
}) })
it('metadata-only policy blocks ad-hoc feed relays but allows profile mirrors at connect time', () => { it('metadata-only policy blocks ad-hoc feed relays at connect time', () => {
setRestrictConnectionsToMetadataRelaysOnly(true) setRestrictConnectionsToMetadataRelaysOnly(true)
setViewerPersonalRelayKeys(buildPersonalRelayKeySet(['wss://relay.example.com/']), { viewerActive: true }) setViewerPersonalRelayKeys(buildPersonalRelayKeySet(['wss://relay.example.com/']), { viewerActive: true })
const urls = [ const urls = [
@ -82,9 +90,9 @@ describe('read-only-relay-personal', () => {
'wss://theforest.nostr1.com/', 'wss://theforest.nostr1.com/',
'wss://nostr.wirednet.jp/' 'wss://nostr.wirednet.jp/'
] ]
expect(sanitizeRelayUrlsForFetch(urls)).toEqual(urls) expect(sanitizeRelayUrlsForFetch(urls)).toEqual(['wss://relay.example.com/'])
expect(isRelayConnectionAllowedForViewer('wss://profiles.nostr1.com/')).toBe(true) expect(isRelayConnectionAllowedForViewer('wss://profiles.nostr1.com/')).toBe(false)
expect(isRelayConnectionAllowedForViewer('wss://thecitadel.nostr1.com/')).toBe(true) expect(isRelayConnectionAllowedForViewer('wss://thecitadel.nostr1.com/')).toBe(false)
expect(isRelayConnectionAllowedForViewer('wss://relay.example.com/')).toBe(true) expect(isRelayConnectionAllowedForViewer('wss://relay.example.com/')).toBe(true)
expect(isRelayConnectionAllowedForViewer('wss://theforest.nostr1.com/')).toBe(false) expect(isRelayConnectionAllowedForViewer('wss://theforest.nostr1.com/')).toBe(false)
expect(isRelayConnectionAllowedForViewer('wss://nostr.wirednet.jp/')).toBe(false) expect(isRelayConnectionAllowedForViewer('wss://nostr.wirednet.jp/')).toBe(false)
@ -97,18 +105,36 @@ describe('read-only-relay-personal', () => {
const urls = ['wss://nostr.land/', AGGR_NOSTR_LAND_WSS, 'wss://nostr.wirednet.jp/'] const urls = ['wss://nostr.land/', AGGR_NOSTR_LAND_WSS, 'wss://nostr.wirednet.jp/']
expect(sanitizeRelayUrlsForFetch(urls).map((u) => u.replace(/\/$/, ''))).toEqual([ expect(sanitizeRelayUrlsForFetch(urls).map((u) => u.replace(/\/$/, ''))).toEqual([
'wss://nostr.land', 'wss://nostr.land',
'wss://aggr.nostr.land', 'wss://aggr.nostr.land'
'wss://nostr.wirednet.jp'
]) ])
expect(isRelayConnectionAllowedForViewer(AGGR_NOSTR_LAND_WSS)).toBe(true) expect(isRelayConnectionAllowedForViewer(AGGR_NOSTR_LAND_WSS)).toBe(true)
expect(isRelayConnectionAllowedForViewer('wss://nostr.wirednet.jp/')).toBe(false) expect(isRelayConnectionAllowedForViewer('wss://nostr.wirednet.jp/')).toBe(false)
}) })
it('metadata-only policy allows profile bootstrap relays at connect time', () => { it('operation scope allows document and gif constant relays during fetch', () => {
setRestrictConnectionsToMetadataRelaysOnly(true) setRestrictConnectionsToMetadataRelaysOnly(true)
setViewerPersonalRelayKeys(new Set(), { viewerActive: true }) setViewerPersonalRelayKeys(new Set(), { viewerActive: true })
expect(isRelayConnectionAllowedForViewer('wss://thecitadel.nostr1.com/')).toBe(false)
expect(isRelayConnectionAllowedForViewer('wss://nostr.wine/')).toBe(false)
const revoke = grantRelayConnectionOperationScope([
'wss://thecitadel.nostr1.com/',
'wss://nostr.wine/',
'wss://essayist.decentnewsroom.com/'
])
expect(isRelayConnectionAllowedForViewer('wss://thecitadel.nostr1.com/')).toBe(true) expect(isRelayConnectionAllowedForViewer('wss://thecitadel.nostr1.com/')).toBe(true)
expect(isRelayConnectionAllowedForViewer('wss://nostr.wine/')).toBe(false)
expect(isRelayConnectionAllowedForViewer('wss://essayist.decentnewsroom.com/')).toBe(true)
revoke()
})
it('metadata-only policy allows curated relays only during an operation scope', () => {
setRestrictConnectionsToMetadataRelaysOnly(true)
setViewerPersonalRelayKeys(new Set(), { viewerActive: true })
expect(isRelayConnectionAllowedForViewer('wss://profiles.nostr1.com/')).toBe(false)
const revoke = grantRelayConnectionOperationScope(['wss://profiles.nostr1.com/'])
expect(isRelayConnectionAllowedForViewer('wss://profiles.nostr1.com/')).toBe(true) expect(isRelayConnectionAllowedForViewer('wss://profiles.nostr1.com/')).toBe(true)
revoke()
expect(isRelayConnectionAllowedForViewer('wss://profiles.nostr1.com/')).toBe(false)
}) })
it('metadata-only policy allows viewer cache and HTTP index relays', () => { it('metadata-only policy allows viewer cache and HTTP index relays', () => {
@ -135,4 +161,26 @@ describe('read-only-relay-personal', () => {
expect(isRelayConnectionAllowedForViewer('wss://relay.damus.io/')).toBe(true) expect(isRelayConnectionAllowedForViewer('wss://relay.damus.io/')).toBe(true)
leaveMetadataRelaysOnlyBypass() leaveMetadataRelaysOnlyBypass()
}) })
it('explicit single-relay browse keeps user-blocked and non-list relays', () => {
setRestrictConnectionsToMetadataRelaysOnly(true)
setViewerPersonalRelayKeys(buildPersonalRelayKeySet(['wss://nostr.land/']), { viewerActive: true })
setViewerBlockedRelayUrls(['wss://relay.layer.systems/'])
enterSingleRelayExplicitBrowse()
const target = 'wss://relay.layer.systems/'
expect(sanitizeRelayUrlsForFetch([target])).toEqual([target])
expect(isRelayConnectionAllowedForViewer(target)).toBe(true)
leaveSingleRelayExplicitBrowse()
})
it('operation scope grants an explicit single-relay target under metadata-only', () => {
setRestrictConnectionsToMetadataRelaysOnly(true)
setViewerPersonalRelayKeys(new Set(), { viewerActive: true })
const leaveFetchScope = enterSingleRelayExplicitFetchScope()
const target = 'wss://relay.layer.systems/'
const revokeScope = grantRelayConnectionOperationScope([target])
expect(isRelayConnectionAllowedForViewer(target)).toBe(true)
revokeScope()
leaveFetchScope()
})
}) })

124
src/lib/read-only-relay-personal.ts

@ -1,7 +1,7 @@
import { import {
READ_ONLY_PERSONAL_LIST_REQUIRED_RELAY_URLS READ_ONLY_PERSONAL_LIST_REQUIRED_RELAY_URLS
} from '@/constants' } from '@/constants'
import { isMetadataPolicyProfileRelay } from '@/lib/metadata-policy-curated-relays' import { isMetadataPolicyOperationScopedRelay } from '@/lib/metadata-policy-curated-relays'
import { import {
filterAggrNostrLandUnlessViewerEligible, filterAggrNostrLandUnlessViewerEligible,
getViewerRelayStackNostrLandAggrEligible, getViewerRelayStackNostrLandAggrEligible,
@ -23,6 +23,15 @@ let viewerMetadataRelaysPolicyActive = false
let restrictConnectionsToMetadataRelaysOnly = false let restrictConnectionsToMetadataRelaysOnly = false
/** Relay explore / search UI: metadata-only policy must not narrow relays on those pages. */ /** Relay explore / search UI: metadata-only policy must not narrow relays on those pages. */
let metadataRelaysOnlyBypassDepth = 0 let metadataRelaysOnlyBypassDepth = 0
/** Relay detail page mounted: explicit single-relay browse must not be blocked by strikes / user blocks / list gates. */
let singleRelayExplicitBrowseDepth = 0
/** In-flight authoritative single-relay timeline REQ (see {@link enterSingleRelayExplicitFetchScope}). */
let singleRelayExplicitFetchDepth = 0
/** In-flight query/subscribe URLs (constants + caller stack) allowed to connect briefly under metadata-only policy. */
const operationScopedRelayKeys = new Set<string>()
/** Dispatched when metadata-only relay policy toggles (feeds should rebuild relay URL lists). */
export const METADATA_RELAYS_ONLY_POLICY_CHANGED_EVENT = 'jumble:metadata-relays-only-changed'
export function setRestrictConnectionsToMetadataRelaysOnly(enabled: boolean): void { export function setRestrictConnectionsToMetadataRelaysOnly(enabled: boolean): void {
restrictConnectionsToMetadataRelaysOnly = enabled restrictConnectionsToMetadataRelaysOnly = enabled
@ -44,6 +53,44 @@ export function isMetadataRelaysOnlyBypassActive(): boolean {
return metadataRelaysOnlyBypassDepth > 0 return metadataRelaysOnlyBypassDepth > 0
} }
export function enterSingleRelayExplicitBrowse(): void {
singleRelayExplicitBrowseDepth++
}
export function leaveSingleRelayExplicitBrowse(): void {
singleRelayExplicitBrowseDepth = Math.max(0, singleRelayExplicitBrowseDepth - 1)
}
export function isSingleRelayExplicitBrowseActive(): boolean {
return singleRelayExplicitBrowseDepth > 0
}
/** While active, {@link sanitizeRelayUrlsForFetch} and pool connects honor the lone target relay. */
export function enterSingleRelayExplicitFetchScope(): () => void {
singleRelayExplicitFetchDepth++
return () => {
singleRelayExplicitFetchDepth = Math.max(0, singleRelayExplicitFetchDepth - 1)
}
}
export function isSingleRelayExplicitFetchScopeActive(): boolean {
return singleRelayExplicitFetchDepth > 0
}
/** True while an explicit single-relay browse page or authoritative timeline REQ is active. */
export function isSingleRelayExplicitPolicyActive(): boolean {
return isSingleRelayExplicitBrowseActive() || isSingleRelayExplicitFetchScopeActive()
}
function shouldPreserveExplicitSingleRelay(
urls: readonly string[],
preserveExplicitSingleRelay?: boolean
): boolean {
if (urls.length !== 1) return false
if (preserveExplicitSingleRelay === true) return true
return isSingleRelayExplicitPolicyActive()
}
/** Logged-in viewer with metadata-only mode: only connect reads to the viewer's relay lists. */ /** Logged-in viewer with metadata-only mode: only connect reads to the viewer's relay lists. */
export function isMetadataRelaysOnlyPolicyActive(): boolean { export function isMetadataRelaysOnlyPolicyActive(): boolean {
return ( return (
@ -60,17 +107,51 @@ export function isRelayUrlInViewerMetadataLists(url: string): boolean {
/** /**
* Under metadata-only policy: viewer NIP-65 / favorites / cache / HTTP lists, plus aggr.nostr.land when * Under metadata-only policy: viewer NIP-65 / favorites / cache / HTTP lists, plus aggr.nostr.land when
* wss://nostr.land is listed, plus {@link PROFILE_RELAY_URLS} for kind-0 / profile hydration. * wss://nostr.land is listed, plus relays in an active {@link grantRelayConnectionOperationScope}.
*/ */
export function isRelayAllowedUnderMetadataOnlyPolicy(url: string): boolean { export function isRelayAllowedUnderMetadataOnlyPolicy(url: string): boolean {
if (isRelayUrlInViewerMetadataLists(url)) return true if (isRelayUrlInViewerMetadataLists(url)) return true
if (getViewerRelayStackNostrLandAggrEligible() && relayUrlIsAggrNostrLand(url)) return true if (getViewerRelayStackNostrLandAggrEligible() && relayUrlIsAggrNostrLand(url)) return true
if (isMetadataPolicyProfileRelay(url)) return true const key = relayUrlKey(url)
if (key.length > 0 && operationScopedRelayKeys.has(key)) return true
return false return false
} }
/**
* Allow read connects to non-personal relays only for the lifetime of an in-flight query/subscribe.
* Under metadata-only policy, only {@link isMetadataPolicyOperationScopedRelay} URLs are granted
* (document / GIF / profile stacks not FAST_READ or feed widening).
*/
export function grantRelayConnectionOperationScope(urls: readonly string[]): () => void {
if (!isMetadataRelaysOnlyPolicyActive()) return () => {}
const added: string[] = []
for (const raw of urls) {
if (isRelayUrlInViewerMetadataLists(raw)) continue
if (getViewerRelayStackNostrLandAggrEligible() && relayUrlIsAggrNostrLand(raw)) continue
if (
!isMetadataPolicyOperationScopedRelay(raw) &&
!(urls.length === 1 && isSingleRelayExplicitPolicyActive())
) {
continue
}
const key = relayUrlKey(raw)
if (!key || operationScopedRelayKeys.has(key)) continue
operationScopedRelayKeys.add(key)
added.push(key)
}
return () => {
for (const key of added) operationScopedRelayKeys.delete(key)
}
}
/** @internal */
export function resetRelayConnectionOperationScopeForTests(): void {
operationScopedRelayKeys.clear()
}
/** Block read-side pool connects / HTTP index fetches when metadata-only policy is on. */ /** Block read-side pool connects / HTTP index fetches when metadata-only policy is on. */
export function isRelayConnectionAllowedForViewer(url: string): boolean { export function isRelayConnectionAllowedForViewer(url: string): boolean {
if (isSingleRelayExplicitPolicyActive()) return true
if (!isMetadataRelaysOnlyPolicyActive()) return true if (!isMetadataRelaysOnlyPolicyActive()) return true
return isRelayAllowedUnderMetadataOnlyPolicy(url) return isRelayAllowedUnderMetadataOnlyPolicy(url)
} }
@ -126,6 +207,29 @@ function isAllowedForKeys(url: string, personalKeys: ReadonlySet<string>): boole
return key.length > 0 && personalKeys.has(key) return key.length > 0 && personalKeys.has(key)
} }
/** Under metadata-only policy: viewer relay lists + aggr.nostr.land when nostr.land is listed. */
function filterRelayUrlsToMetadataOnlyPersonalLists(
urls: readonly string[],
personalKeys: ReadonlySet<string>
): string[] {
return urls.filter((u) => {
const key = relayUrlKey(u)
if (key.length > 0 && personalKeys.has(key)) return true
if (getViewerRelayStackNostrLandAggrEligible() && relayUrlIsAggrNostrLand(u)) return true
return false
})
}
/** When metadata-only is on, omit global FAST_READ / trending widening from REQ stacks. */
export function viewerIncludeGlobalFastReadRelayLayer(): boolean {
return !isMetadataRelaysOnlyPolicyActive()
}
/** When metadata-only is on, omit {@link FAST_WRITE_RELAY_URLS} from read-side merge/fetch stacks (publish unchanged). */
export function viewerIncludeGlobalFastWriteRelayLayer(): boolean {
return !isMetadataRelaysOnlyPolicyActive()
}
/** /**
* Drop {@link READ_ONLY_PERSONAL_LIST_REQUIRED_RELAY_URLS} unless the viewer listed them on NIP-65 / favorites / 10432. * 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. * Other read-only index relays (aggr.nostr.land, search.nos.today, ) are unchanged.
@ -144,17 +248,27 @@ export function filterReadOnlyRelaysUnlessPersonal(
*/ */
export function sanitizeRelayUrlsForFetch( export function sanitizeRelayUrlsForFetch(
urls: readonly string[], urls: readonly string[],
personalKeys?: ReadonlySet<string> personalKeys?: ReadonlySet<string>,
opts?: { preserveExplicitSingleRelay?: boolean }
): string[] { ): string[] {
if (shouldPreserveExplicitSingleRelay(urls, opts?.preserveExplicitSingleRelay)) {
const raw = urls[0]!.trim()
if (!raw) return []
return [normalizeAnyRelayUrl(raw) || raw]
}
const keys = personalKeys ?? viewerPersonalRelayKeys const keys = personalKeys ?? viewerPersonalRelayKeys
const withoutThirdPartyLocals = urls.filter((u) => { const withoutThirdPartyLocals = urls.filter((u) => {
if (urlIsNonLocalForRemoteViewer(u)) return true if (urlIsNonLocalForRemoteViewer(u)) return true
const key = relayUrlKey(u) const key = relayUrlKey(u)
return key.length > 0 && keys.has(key) return key.length > 0 && keys.has(key)
}) })
return filterViewerBlockedRelaysForFetch( let out = filterViewerBlockedRelaysForFetch(
filterAggrNostrLandUnlessViewerEligible( filterAggrNostrLandUnlessViewerEligible(
filterReadOnlyRelaysUnlessPersonal(withoutThirdPartyLocals, keys) filterReadOnlyRelaysUnlessPersonal(withoutThirdPartyLocals, keys)
) )
) )
if (isMetadataRelaysOnlyPolicyActive()) {
out = filterRelayUrlsToMetadataOnlyPersonalLists(out, keys)
}
return out
} }

6
src/lib/relay-list-builder.ts

@ -23,7 +23,7 @@ import {
normalizeHttpRelayUrl, normalizeHttpRelayUrl,
normalizeUrl normalizeUrl
} from '@/lib/url' } from '@/lib/url'
import { buildPersonalRelayKeySet, sanitizeRelayUrlsForFetch } from '@/lib/read-only-relay-personal' import { buildPersonalRelayKeySet, sanitizeRelayUrlsForFetch, isMetadataRelaysOnlyPolicyActive } from '@/lib/read-only-relay-personal'
import { getCacheRelayUrls } from './private-relays' import { getCacheRelayUrls } from './private-relays'
import { defaultFavoriteRelaysForViewer, viewerUsesGlobalRelayDefaults } from '@/lib/viewer-relay-defaults' import { defaultFavoriteRelaysForViewer, viewerUsesGlobalRelayDefaults } from '@/lib/viewer-relay-defaults'
import client from '@/services/client.service' import client from '@/services/client.service'
@ -206,7 +206,9 @@ export async function buildComprehensiveRelayList(options: RelayListBuilderOptio
] ]
let effectiveIncludeFastRead = includeFastReadRelays let effectiveIncludeFastRead = includeFastReadRelays
if (userPubkey && includeFastReadRelays) { if (isMetadataRelaysOnlyPolicyActive()) {
effectiveIncludeFastRead = false
} else if (userPubkey && includeFastReadRelays) {
if (useGlobalRelayDefaultsOption !== undefined) { if (useGlobalRelayDefaultsOption !== undefined) {
effectiveIncludeFastRead = useGlobalRelayDefaultsOption effectiveIncludeFastRead = useGlobalRelayDefaultsOption
} else { } else {

7
src/lib/relay-strikes.test.ts

@ -85,6 +85,13 @@ describe('relaySessionStrikes.clearKey', () => {
relaySessionStrikes.reset() relaySessionStrikes.reset()
}) })
it('recordConnectionFailure applies rate-limit cooldown on HTTP 429', () => {
const url = 'wss://relay.layer.systems/'
relaySessionStrikes.recordConnectionFailure(url, 'HTTP/1.1 429 Too Many Requests')
expect(relaySessionStrikes.isRateLimited(url)).toBe(true)
expect(relaySessionStrikes.isReadHttpSkipped(url)).toBe(true)
})
it('removes strike state so relay is no longer skipped', () => { it('removes strike state so relay is no longer skipped', () => {
const url = 'ws://localhost:4000/' const url = 'ws://localhost:4000/'
relaySessionStrikes.applyRateLimitCooldownForUrl(url) relaySessionStrikes.applyRateLimitCooldownForUrl(url)

35
src/lib/relay-strikes.ts

@ -21,6 +21,8 @@ const STRIKE_COOLDOWN_MS = 3 * 60 * 1000
/** Rate-limit style NOTICE / overload → cool down without incrementing strike counter. */ /** Rate-limit style NOTICE / overload → cool down without incrementing strike counter. */
const RATE_LIMIT_COOLDOWN_MS = 10 * 60 * 1000 const RATE_LIMIT_COOLDOWN_MS = 10 * 60 * 1000
/** HTTP 429 on WebSocket handshake: shorter backoff so explicit relay browse recovers after accidental hammering. */
const CONNECTION_RATE_LIMIT_COOLDOWN_MS = 90 * 1000
/** Non–cache-relay failures: at most one strike increment per key per this window. */ /** Non–cache-relay failures: at most one strike increment per key per this window. */
const STRIKE_INCREMENT_DEBOUNCE_MS = 30 * 1000 const STRIKE_INCREMENT_DEBOUNCE_MS = 30 * 1000
@ -123,6 +125,15 @@ class RelaySessionStrikes {
return e return e
} }
/** True when a relay NOTICE or connection error put this URL in rate-limit cooldown. */
isRateLimited(url: string): boolean {
const key = sessionKey(url)
if (!key) return false
const e = this.byKey.get(key)
if (!e) return false
return Date.now() < e.rateLimitUntil
}
/** True when read / WS / HTTP index fetch should omit this relay (unless single-relay override). */ /** True when read / WS / HTTP index fetch should omit this relay (unless single-relay override). */
isReadHttpSkipped(url: string): boolean { isReadHttpSkipped(url: string): boolean {
const key = sessionKey(url) const key = sessionKey(url)
@ -132,6 +143,19 @@ class RelaySessionStrikes {
return Date.now() < Math.max(e.rateLimitUntil, e.readStrikeSkipUntil, e.slowParkUntil) return Date.now() < Math.max(e.rateLimitUntil, e.readStrikeSkipUntil, e.slowParkUntil)
} }
/** WS/HTTP connect failure: rate-limit style errors cool down without accruing read strikes. */
recordConnectionFailure(url: string, message: string, source: 'connection' | 'http' = 'connection'): void {
if (classifyRelayNotice(message) === 'rate_limit') {
if (source === 'connection') {
this.applyConnectionRateLimitCooldownForUrl(url)
} else {
this.applyRateLimitCooldownForUrl(url)
}
return
}
this.recordReadFailure(url, source)
}
/** True when publish should omit this relay (unless single-target override). */ /** True when publish should omit this relay (unless single-target override). */
isPublishSkipped(url: string): boolean { isPublishSkipped(url: string): boolean {
const key = sessionKey(url) const key = sessionKey(url)
@ -156,12 +180,17 @@ class RelaySessionStrikes {
applyRateLimitCooldownForUrl(url: string): void { applyRateLimitCooldownForUrl(url: string): void {
const key = sessionKey(url) const key = sessionKey(url)
if (key) this.applyRateLimitCooldownKey(key) if (key) this.applyRateLimitCooldownKey(key, RATE_LIMIT_COOLDOWN_MS)
}
applyConnectionRateLimitCooldownForUrl(url: string): void {
const key = sessionKey(url)
if (key) this.applyRateLimitCooldownKey(key, CONNECTION_RATE_LIMIT_COOLDOWN_MS)
} }
private applyRateLimitCooldownKey(key: string): void { private applyRateLimitCooldownKey(key: string, cooldownMs = RATE_LIMIT_COOLDOWN_MS): void {
const e = this.getEntry(key) const e = this.getEntry(key)
e.rateLimitUntil = Math.max(e.rateLimitUntil, Date.now() + RATE_LIMIT_COOLDOWN_MS) e.rateLimitUntil = Math.max(e.rateLimitUntil, Date.now() + cooldownMs)
} }
/** WS connect failure, HTTP transport failure, etc. */ /** WS connect failure, HTTP transport failure, etc. */

25
src/lib/single-relay-browse-kinds.test.ts

@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest'
import { kinds } from 'nostr-tools'
import { NIP_SEARCH_DOCUMENT_KINDS } from '@/constants'
import { kindsForSingleRelayBrowse, relayUrlIsDocumentRelay } from './single-relay-browse-kinds'
describe('single-relay-browse-kinds', () => {
it('detects document relays', () => {
expect(relayUrlIsDocumentRelay('wss://essayist.decentnewsroom.com/')).toBe(true)
expect(relayUrlIsDocumentRelay('wss://relay.damus.io/')).toBe(false)
})
it('merges document kinds for document relays', () => {
const out = kindsForSingleRelayBrowse('wss://essayist.decentnewsroom.com/', [kinds.ShortTextNote])
expect(out).toContain(kinds.ShortTextNote)
for (const k of NIP_SEARCH_DOCUMENT_KINDS) {
expect(out).toContain(k)
}
})
it('keeps picker kinds only for generic relays', () => {
expect(kindsForSingleRelayBrowse('wss://relay.damus.io/', [kinds.ShortTextNote])).toEqual([
kinds.ShortTextNote
])
})
})

19
src/lib/single-relay-browse-kinds.ts

@ -0,0 +1,19 @@
import { DOCUMENT_RELAY_URLS, NIP_SEARCH_DOCUMENT_KINDS } from '@/constants'
import { canonicalRelaySessionKey } from '@/lib/url'
import { kinds } from 'nostr-tools'
const documentRelayKeySet = new Set(
DOCUMENT_RELAY_URLS.map((u) => canonicalRelaySessionKey(u)).filter(Boolean)
)
export function relayUrlIsDocumentRelay(url: string): boolean {
const key = canonicalRelaySessionKey(url)
return key.length > 0 && documentRelayKeySet.has(key)
}
/** Kinds for a single-relay browse REQ (picker + document kinds on document relays). */
export function kindsForSingleRelayBrowse(relayUrl: string, showKinds: readonly number[]): number[] {
const base = showKinds.length > 0 ? [...showKinds] : [kinds.ShortTextNote]
if (!relayUrlIsDocumentRelay(relayUrl)) return base
return [...new Set([...base, ...NIP_SEARCH_DOCUMENT_KINDS])]
}

17
src/providers/FeedProvider.test.ts

@ -2,7 +2,11 @@ import { describe, expect, it } from 'vitest'
import { feedRelayPolicyUrls } from '@/features/feed/relay-policy' import { feedRelayPolicyUrls } from '@/features/feed/relay-policy'
import { AGGR_NOSTR_LAND_WSS } from '@/lib/nostr-land-aggr' import { AGGR_NOSTR_LAND_WSS } from '@/lib/nostr-land-aggr'
import { buildAllFavoritesFeedRelayUrls, stripNostrLandAggrFromRelayUrls } from '@/lib/home-feed-relays' import { buildAllFavoritesFeedRelayUrls, stripNostrLandAggrFromRelayUrls } from '@/lib/home-feed-relays'
import { buildWispTrendingNotesRelayUrl } from '@/lib/wisp-trending-relay' import { buildWispTrendingNotesRelayUrl, isWispTrendingNotesRelayUrl } from '@/lib/wisp-trending-relay'
import {
setRestrictConnectionsToMetadataRelaysOnly,
setViewerPersonalRelayKeys
} from '@/lib/read-only-relay-personal'
describe('home feed relay policy', () => { describe('home feed relay policy', () => {
it('keeps aggr.nostr.land out of the main home feed', () => { it('keeps aggr.nostr.land out of the main home feed', () => {
@ -39,6 +43,17 @@ describe('home feed relay policy', () => {
expect(merged).toContain('wss://inbox.example/') expect(merged).toContain('wss://inbox.example/')
}) })
it('metadata-only policy omits wisp trending from home feed relay list', () => {
setRestrictConnectionsToMetadataRelaysOnly(true)
setViewerPersonalRelayKeys(new Set(['wss://relay.example.com/']), { viewerActive: true })
const wisp = buildWispTrendingNotesRelayUrl()
const urls = buildAllFavoritesFeedRelayUrls(['wss://relay.example.com/'], [], [wisp])
expect(urls).toContain('wss://relay.example.com/')
expect(urls.some((u) => isWispTrendingNotesRelayUrl(u))).toBe(false)
setRestrictConnectionsToMetadataRelaysOnly(false)
setViewerPersonalRelayKeys(new Set(), { viewerActive: false })
})
it('stripNostrLandAggrFromRelayUrls removes aggr with trailing slash and hostname variants', () => { it('stripNostrLandAggrFromRelayUrls removes aggr with trailing slash and hostname variants', () => {
const stripped = stripNostrLandAggrFromRelayUrls([ const stripped = stripNostrLandAggrFromRelayUrls([
'wss://relay.example/', 'wss://relay.example/',

7
src/providers/FeedProvider.tsx

@ -6,6 +6,7 @@ import {
syncViewerRelayStackNostrLandAggrEligible, syncViewerRelayStackNostrLandAggrEligible,
urlsForViewerNostrLandAggrEligibilitySync urlsForViewerNostrLandAggrEligibilitySync
} from '@/lib/nostr-land-relay-eligibility' } from '@/lib/nostr-land-relay-eligibility'
import { METADATA_RELAYS_ONLY_POLICY_CHANGED_EVENT } from '@/lib/read-only-relay-personal'
import { collectUserReadInboxUrls } from '@/lib/viewer-read-inboxes' import { collectUserReadInboxUrls } from '@/lib/viewer-read-inboxes'
import { collectUserWriteOutboxUrls } from '@/lib/viewer-write-outboxes' import { collectUserWriteOutboxUrls } from '@/lib/viewer-write-outboxes'
import { getCacheRelayUrlsFromEvent } from '@/lib/private-relays' import { getCacheRelayUrlsFromEvent } from '@/lib/private-relays'
@ -264,6 +265,12 @@ export function FeedProvider({ children }: { children: ReactNode }) {
} }
}, [isInitialized, favoriteRelaysIdentity, blockedRelaysIdentity, replyExtraRelaysIdentity, updateFeedRelayUrls]) }, [isInitialized, favoriteRelaysIdentity, blockedRelaysIdentity, replyExtraRelaysIdentity, updateFeedRelayUrls])
useEffect(() => {
const onPolicyChange = () => updateFeedRelayUrls()
window.addEventListener(METADATA_RELAYS_ONLY_POLICY_CHANGED_EVENT, onPolicyChange)
return () => window.removeEventListener(METADATA_RELAYS_ONLY_POLICY_CHANGED_EVENT, onPolicyChange)
}, [updateFeedRelayUrls])
return ( return (
<FeedContext.Provider <FeedContext.Provider
value={useMemo( value={useMemo(

16
src/services/client-query.service.ts

@ -40,7 +40,8 @@ import { patchRelayNoticeForFetchFailures } from '@/services/relay-notice-fetch-
import type { Filter, Event as NEvent } from 'nostr-tools' import type { Filter, Event as NEvent } from 'nostr-tools'
import { SimplePool, EventTemplate, VerifiedEvent, nip19 } from 'nostr-tools' import { SimplePool, EventTemplate, VerifiedEvent, nip19 } from 'nostr-tools'
import type { AbstractRelay } from 'nostr-tools/abstract-relay' import type { AbstractRelay } from 'nostr-tools/abstract-relay'
import { sanitizeRelayUrlsForFetch, isRelayConnectionAllowedForViewer } from '@/lib/read-only-relay-personal' import { sanitizeRelayUrlsForFetch, isRelayConnectionAllowedForViewer, grantRelayConnectionOperationScope } from '@/lib/read-only-relay-personal'
import { closeRelayPoolSocketsIfIdle } from '@/lib/relay-pool-idle'
import { publicReadRelayFallbackUrls } from '@/lib/viewer-relay-defaults' import { publicReadRelayFallbackUrls } from '@/lib/viewer-relay-defaults'
import nip66Service from './nip66.service' import nip66Service from './nip66.service'
import type { ISigner, TSignerType } from '@/types' import type { ISigner, TSignerType } from '@/types'
@ -399,6 +400,8 @@ export class QueryService {
if (queue?.length) { if (queue?.length) {
const next = queue.shift()! const next = queue.shift()!
next() next()
} else if (count === 0) {
queueMicrotask(() => closeRelayPoolSocketsIfIdle([relayKey]))
} }
} }
@ -532,6 +535,7 @@ export class QueryService {
} }
const resultPromise = new Promise<NEvent[]>((resolve) => { const resultPromise = new Promise<NEvent[]>((resolve) => {
const revokeOperationScope = grantRelayConnectionOperationScope(urls)
const events: NEvent[] = [] const events: NEvent[] = []
const cancelAbortRegistrations: Array<() => void> = [] const cancelAbortRegistrations: Array<() => void> = []
const abortHttp = new AbortController() const abortHttp = new AbortController()
@ -640,6 +644,8 @@ export class QueryService {
} }
cancelAbortRegistrations.length = 0 cancelAbortRegistrations.length = 0
resolved = true resolved = true
revokeOperationScope()
closeRelayPoolSocketsIfIdle([...wsQueryUrls, ...httpRelayBases])
if (resolveTimeout) clearTimeout(resolveTimeout) if (resolveTimeout) clearTimeout(resolveTimeout)
if (firstResultGraceTimeoutId) clearTimeout(firstResultGraceTimeoutId) if (firstResultGraceTimeoutId) clearTimeout(firstResultGraceTimeoutId)
if (feedFirstResultGraceTimeoutId) clearTimeout(feedFirstResultGraceTimeoutId) if (feedFirstResultGraceTimeoutId) clearTimeout(feedFirstResultGraceTimeoutId)
@ -868,6 +874,8 @@ export class QueryService {
return { close: () => {} } return { close: () => {} }
} }
const revokeOperationScope = grantRelayConnectionOperationScope(relays)
const _knownIds = new Set<string>() const _knownIds = new Set<string>()
const grouped = new Map<string, Filter[]>() const grouped = new Map<string, Filter[]>()
for (const url of relays) { for (const url of relays) {
@ -1101,7 +1109,11 @@ export class QueryService {
// relay is mis-labeled "skipped" in batch_end. // relay is mis-labeled "skipped" in batch_end.
void allOpened.then(() => { void allOpened.then(() => {
subs.forEach(({ close: subClose }) => subClose()) subs.forEach(({ close: subClose }) => subClose())
setTimeout(() => opBatch?.finalize('closed', 'subscribe_close'), 0) setTimeout(() => {
opBatch?.finalize('closed', 'subscribe_close')
revokeOperationScope()
closeRelayPoolSocketsIfIdle(relays)
}, 0)
}) })
} }
} }

92
src/services/client.service.ts

@ -55,6 +55,11 @@ import {
isRelayConnectionAllowedForViewer, isRelayConnectionAllowedForViewer,
isMetadataRelaysOnlyPolicyActive, isMetadataRelaysOnlyPolicyActive,
isRestrictConnectionsToMetadataRelaysOnly, isRestrictConnectionsToMetadataRelaysOnly,
grantRelayConnectionOperationScope,
enterSingleRelayExplicitFetchScope,
isSingleRelayExplicitBrowseActive,
isSingleRelayExplicitFetchScopeActive,
isSingleRelayExplicitPolicyActive,
setViewerPersonalRelayKeys setViewerPersonalRelayKeys
} from '@/lib/read-only-relay-personal' } from '@/lib/read-only-relay-personal'
import { import {
@ -116,7 +121,12 @@ function sanitizeSubscribeFiltersBeforeReq(filter: Filter | Filter[]): Filter[]
return asArray.map(sanitizeETagFilterForSubscribe).filter((f): f is Filter => !!f) return asArray.map(sanitizeETagFilterForSubscribe).filter((f): f is Filter => !!f)
} }
function withDocumentRelayUrlsForFilters(relays: string[], filters: Filter[]): string[] { function withDocumentRelayUrlsForFilters(
relays: string[],
filters: Filter[],
opts?: { singleRelayFeed?: boolean }
): string[] {
if (opts?.singleRelayFeed) return relays
if (!filters.some((f) => relayFilterIncludesDocumentRelayKind(f))) return relays if (!filters.some((f) => relayFilterIncludesDocumentRelayKind(f))) return relays
return dedupeNormalizeRelayUrlsOrdered([...relays, ...DOCUMENT_RELAY_URLS]) return dedupeNormalizeRelayUrlsOrdered([...relays, ...DOCUMENT_RELAY_URLS])
} }
@ -190,7 +200,7 @@ import {
urlMatchesConfiguredHttpIndexRelay urlMatchesConfiguredHttpIndexRelay
} from '@/lib/url' } from '@/lib/url'
import { canonicalFeedFilter, canonicalRelayUrls } from '@/features/feed/descriptor' import { canonicalFeedFilter, canonicalRelayUrls } from '@/features/feed/descriptor'
import { initRelayPoolIdle, touchRelayPoolActivity } from '@/lib/relay-pool-idle' import { initRelayPoolIdle, touchRelayPoolActivity, closeRelayPoolSocketsIfIdle } from '@/lib/relay-pool-idle'
import { relaySessionStrikes } from '@/lib/relay-strikes' import { relaySessionStrikes } from '@/lib/relay-strikes'
import { isSafari } from '@/lib/utils' import { isSafari } from '@/lib/utils'
import { import {
@ -466,9 +476,14 @@ class ClientService extends EventTarget {
if (params?.purpose !== 'write' && !isRelayConnectionAllowedForViewer(url)) { if (params?.purpose !== 'write' && !isRelayConnectionAllowedForViewer(url)) {
throw new Error(`[metadata-relays-only] skipping relay ${url}`) throw new Error(`[metadata-relays-only] skipping relay ${url}`)
} }
if (params?.purpose !== 'write' && relaySessionStrikes.isReadHttpSkipped(url)) { if (params?.purpose !== 'write') {
if (relaySessionStrikes.isRateLimited(url)) {
throw new Error(`[relay-rate-limit] skipping relay ${url}`)
}
if (!isSingleRelayExplicitPolicyActive() && relaySessionStrikes.isReadHttpSkipped(url)) {
throw new Error(`[relay-strike] skipping unresponsive relay ${url}`) throw new Error(`[relay-strike] skipping unresponsive relay ${url}`)
} }
}
if (!isWebsocketUrl(url) && isKind10243HttpRelayTagUrl(url)) { if (!isWebsocketUrl(url) && isKind10243HttpRelayTagUrl(url)) {
throw new Error(`[http-index-relay] ${url} uses the HTTPS index API, not WebSocket`) throw new Error(`[http-index-relay] ${url} uses the HTTPS index API, not WebSocket`)
} }
@ -489,10 +504,11 @@ class ClientService extends EventTarget {
params?.purpose !== 'write' && params?.purpose !== 'write' &&
!msg.includes('[metadata-relays-only]') && !msg.includes('[metadata-relays-only]') &&
!msg.includes('[relay-strike]') && !msg.includes('[relay-strike]') &&
!msg.includes('[relay-rate-limit]') &&
!msg.includes('[offline]') && !msg.includes('[offline]') &&
!msg.includes('[http-index-relay]') !msg.includes('[http-index-relay]')
) { ) {
relaySessionStrikes.recordReadFailure(url, 'connection') relaySessionStrikes.recordConnectionFailure(url, msg, 'connection')
} }
throw err throw err
} }
@ -1772,6 +1788,7 @@ class ClientService extends EventTarget {
publishOpBatch.record(idx, url, rs?.success === true, rs?.error) publishOpBatch.record(idx, url, rs?.success === true, rs?.error)
}) })
publishOpBatch.logEnd(status) publishOpBatch.logEnd(status)
queueMicrotask(() => closeRelayPoolSocketsIfIdle(publishTargetUrls))
} }
/** /**
@ -2598,7 +2615,8 @@ class ClientService extends EventTarget {
onclose, onclose,
startLogin, startLogin,
onAllClose, onAllClose,
connectionSlotPriority connectionSlotPriority,
singleRelayExplicit
}: { }: {
onevent?: (evt: NEvent) => void onevent?: (evt: NEvent) => void
oneose?: (eosed: boolean) => void oneose?: (eosed: boolean) => void
@ -2607,17 +2625,25 @@ class ClientService extends EventTarget {
onAllClose?: (reasons: string[]) => void onAllClose?: (reasons: string[]) => void
/** Jump the global connection queue (single-relay authoritative timelines). */ /** Jump the global connection queue (single-relay authoritative timelines). */
connectionSlotPriority?: boolean connectionSlotPriority?: boolean
/** Authoritative single-relay timeline: keep the target relay through sanitizers and strikes. */
singleRelayExplicit?: boolean
}, },
relayReqLog?: { groupId?: string; onBatchEnd?: (rows: RelayOpTerminalRow[]) => void } relayReqLog?: { groupId?: string; onBatchEnd?: (rows: RelayOpTerminalRow[]) => void }
) { ) {
const originalDedupedRelays = Array.from(new Set(urls)) const originalDedupedRelays = Array.from(new Set(urls))
const preserveExplicitSingleRelay =
originalDedupedRelays.length === 1 &&
(singleRelayExplicit === true || isSingleRelayExplicitBrowseActive())
const revokeFetchScope = preserveExplicitSingleRelay ? enterSingleRelayExplicitFetchScope() : () => {}
const httpKeys = new Set( const httpKeys = new Set(
httpIndexBasesForRelayQuery(originalDedupedRelays, this.viewerHttpIndexRelayBases).map((u) => httpIndexBasesForRelayQuery(originalDedupedRelays, this.viewerHttpIndexRelayBases).map((u) =>
canonicalRelaySessionKey(u) canonicalRelaySessionKey(u)
) )
) )
let relays = sanitizeRelayUrlsForFetch( let relays = sanitizeRelayUrlsForFetch(
originalDedupedRelays.filter((url) => !httpKeys.has(canonicalRelaySessionKey(url))) originalDedupedRelays.filter((url) => !httpKeys.has(canonicalRelaySessionKey(url))),
undefined,
{ preserveExplicitSingleRelay }
) )
if (navigator.onLine) { if (navigator.onLine) {
relays = stripLocalNetworkRelaysForWssReq(relays) relays = stripLocalNetworkRelaysForWssReq(relays)
@ -2640,7 +2666,9 @@ class ClientService extends EventTarget {
} }
} }
relays = withDocumentRelayUrlsForFilters(relays, filters) relays = withDocumentRelayUrlsForFilters(relays, filters, {
singleRelayFeed: originalDedupedRelays.length === 1
})
const stripSocialBlockedRelays = const stripSocialBlockedRelays =
SOCIAL_KIND_BLOCKED_RELAY_URLS.length > 0 && SOCIAL_KIND_BLOCKED_RELAY_URLS.length > 0 &&
@ -2691,8 +2719,9 @@ class ClientService extends EventTarget {
/** /**
* Same rule as {@link QueryService.subscribe}: never `pool.close` a lone relay when the REQ carries NIP-50 * Same rule as {@link QueryService.subscribe}: never `pool.close` a lone relay when the REQ carries NIP-50
* `search` overlapping one-shots (e.g. Strict Mode) otherwise reset the socket before EOSE. * `search` overlapping one-shots (e.g. Strict Mode) otherwise reset the socket before EOSE.
* Also skip for explicit single-relay browse feeds: close+reconnect on every timeline resubscribe triggers 429s.
*/ */
if (groupedRequests.length === 1 && !hasNip50Search) { if (groupedRequests.length === 1 && !hasNip50Search && !preserveExplicitSingleRelay) {
try { try {
this.pool.close([groupedRequests[0]!.url]) this.pool.close([groupedRequests[0]!.url])
} catch { } catch {
@ -2716,6 +2745,8 @@ class ClientService extends EventTarget {
} }
} }
const revokeOperationScope = grantRelayConnectionOperationScope(relays)
const reqGroupId = const reqGroupId =
relayReqLog?.groupId ?? relayReqLog?.groupId ??
`sub-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}` `sub-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`
@ -2807,7 +2838,11 @@ class ClientService extends EventTarget {
relay = await that.pool.ensureRelay(url, { connectionTimeout: RELAY_POOL_CONNECTION_TIMEOUT_MS }) relay = await that.pool.ensureRelay(url, { connectionTimeout: RELAY_POOL_CONNECTION_TIMEOUT_MS })
patchRelayNoticeForFetchFailures(relay, relayKey, (u, m) => that.handleRelayNoticeSession(u, m)) patchRelayNoticeForFetchFailures(relay, relayKey, (u, m) => that.handleRelayNoticeSession(u, m))
} catch (err) { } catch (err) {
relaySessionStrikes.recordReadFailure(url, 'connection') relaySessionStrikes.recordConnectionFailure(
url,
(err as Error)?.message ?? String(err),
'connection'
)
that.queryService.releaseSubSlot(relayKey) that.queryService.releaseSubSlot(relayKey)
handleClose(i, (err as Error)?.message ?? String(err)) handleClose(i, (err as Error)?.message ?? String(err))
return return
@ -2864,7 +2899,11 @@ class ClientService extends EventTarget {
that.handleRelayNoticeSession(u, m) that.handleRelayNoticeSession(u, m)
) )
} catch (err) { } catch (err) {
relaySessionStrikes.recordReadFailure(url, 'connection') relaySessionStrikes.recordConnectionFailure(
url,
(err as Error)?.message ?? String(err),
'connection'
)
nip42ResubscribePending.delete(i) nip42ResubscribePending.delete(i)
that.queryService.releaseSubSlot(relayKey) that.queryService.releaseSubSlot(relayKey)
handleClose(i, (err as Error)?.message ?? String(err)) handleClose(i, (err as Error)?.message ?? String(err))
@ -2969,7 +3008,14 @@ class ClientService extends EventTarget {
this.removeEventListener('newEvent', handleNewEventFromInternal) this.removeEventListener('newEvent', handleNewEventFromInternal)
void allOpened.then(() => { void allOpened.then(() => {
subs.forEach(({ close: subClose }) => subClose()) subs.forEach(({ close: subClose }) => subClose())
setTimeout(() => opBatch.finalize('closed', 'subscription_closed'), 0) setTimeout(() => {
opBatch.finalize('closed', 'subscription_closed')
revokeOperationScope()
revokeFetchScope()
if (!preserveExplicitSingleRelay) {
closeRelayPoolSocketsIfIdle(relays)
}
}, 0)
}) })
} }
} }
@ -3067,7 +3113,9 @@ class ClientService extends EventTarget {
const httpTimelinePollBases = httpIndexBasesForRelayQuery( const httpTimelinePollBases = httpIndexBasesForRelayQuery(
originalDedupedRelays, originalDedupedRelays,
this.viewerHttpIndexRelayBases this.viewerHttpIndexRelayBases
).filter((u) => !relaySessionStrikes.isReadHttpSkipped(u)) ).filter(
(u) => relayAuthoritativeTimeline || !relaySessionStrikes.isReadHttpSkipped(u)
)
let httpPollIntervalId: ReturnType<typeof setInterval> | null = null let httpPollIntervalId: ReturnType<typeof setInterval> | null = null
let httpPollCursorUnix = 0 let httpPollCursorUnix = 0
const clearHttpTimelinePoll = () => { const clearHttpTimelinePoll = () => {
@ -3325,7 +3373,8 @@ class ClientService extends EventTarget {
onclose: onClose, onclose: onClose,
connectionSlotPriority: connectionSlotPriority:
connectionSlotPriority === true || connectionSlotPriority === true ||
(relayAuthoritativeTimeline && wsRelays.length === 1 && navigator.onLine) (relayAuthoritativeTimeline && wsRelays.length === 1 && navigator.onLine),
singleRelayExplicit: relayAuthoritativeTimeline && originalDedupedRelays.length === 1
}, },
httpOnlyShard ? undefined : relayReqLog) httpOnlyShard ? undefined : relayReqLog)
@ -3539,15 +3588,22 @@ class ClientService extends EventTarget {
this.viewerHttpIndexRelayBases this.viewerHttpIndexRelayBases
) )
const httpKeys = new Set(httpRelayBases.map((u) => canonicalRelaySessionKey(u))) const httpKeys = new Set(httpRelayBases.map((u) => canonicalRelaySessionKey(u)))
const preserveExplicitSingleRelay =
originalDedupedRelays.length === 1 &&
(isSingleRelayExplicitBrowseActive() || isSingleRelayExplicitFetchScopeActive())
const wsOriginal = sanitizeRelayUrlsForFetch( const wsOriginal = sanitizeRelayUrlsForFetch(
originalDedupedRelays.filter((url) => !httpKeys.has(canonicalRelaySessionKey(url))) originalDedupedRelays.filter((url) => !httpKeys.has(canonicalRelaySessionKey(url))),
undefined,
{ preserveExplicitSingleRelay }
) )
let relays = [...wsOriginal] let relays = [...wsOriginal]
if (relays.length === 0 && httpRelayBases.length === 0) { if (relays.length === 0 && httpRelayBases.length === 0) {
relays = [...publicReadRelayFallbackUrls()] relays = [...publicReadRelayFallbackUrls()]
} }
const filters = Array.isArray(filter) ? filter : [filter] const filters = Array.isArray(filter) ? filter : [filter]
relays = withDocumentRelayUrlsForFilters(relays, filters) relays = withDocumentRelayUrlsForFilters(relays, filters, {
singleRelayFeed: originalDedupedRelays.length === 1
})
const stripSocialBlockedRelays = const stripSocialBlockedRelays =
SOCIAL_KIND_BLOCKED_RELAY_URLS.length > 0 && SOCIAL_KIND_BLOCKED_RELAY_URLS.length > 0 &&
filters.some((f) => relayFilterIncludesSocialKindBlockedKind(f)) filters.some((f) => relayFilterIncludesSocialKindBlockedKind(f))
@ -3622,12 +3678,6 @@ class ClientService extends EventTarget {
return { events: [], connectionError: e instanceof Error ? e.message : String(e) } return { events: [], connectionError: e instanceof Error ? e.message : String(e) }
} }
} }
try {
await this.pool.ensureRelay(normalized, { connectionTimeout: RELAY_POOL_CONNECTION_TIMEOUT_MS })
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
return { events: [], connectionError: msg }
}
try { try {
const events = await this.queryService.query([normalized], filter, undefined, queryOpts) const events = await this.queryService.query([normalized], filter, undefined, queryOpts)
return { events, connectionError: undefined } return { events, connectionError: undefined }

6
src/services/local-storage.service.ts

@ -124,7 +124,7 @@ class LocalStorageService {
private showPublishSuccessToasts: boolean = false private showPublishSuccessToasts: boolean = false
private showDetailedPublishToasts: boolean = true private showDetailedPublishToasts: boolean = true
private showLiveActivitiesBanner: boolean = true private showLiveActivitiesBanner: boolean = true
private restrictRelaysToMetadataLists: boolean = false private restrictRelaysToMetadataLists: boolean = true
constructor() { constructor() {
if (!LocalStorageService.instance) { if (!LocalStorageService.instance) {
@ -422,7 +422,7 @@ class LocalStorageService {
const restrictMetadataRelaysStr = window.localStorage.getItem( const restrictMetadataRelaysStr = window.localStorage.getItem(
StorageKey.RESTRICT_RELAYS_TO_METADATA_LISTS StorageKey.RESTRICT_RELAYS_TO_METADATA_LISTS
) )
this.restrictRelaysToMetadataLists = restrictMetadataRelaysStr === 'true' this.restrictRelaysToMetadataLists = restrictMetadataRelaysStr !== 'false'
setRestrictConnectionsToMetadataRelaysOnly(this.restrictRelaysToMetadataLists) setRestrictConnectionsToMetadataRelaysOnly(this.restrictRelaysToMetadataLists)
// Clean up deprecated data // Clean up deprecated data
@ -617,7 +617,7 @@ class LocalStorageService {
if (paneStr === 'single' || paneStr === 'double') this.panelMode = paneStr if (paneStr === 'single' || paneStr === 'double') this.panelMode = paneStr
const restrictMetadataRelaysStr = get(StorageKey.RESTRICT_RELAYS_TO_METADATA_LISTS) const restrictMetadataRelaysStr = get(StorageKey.RESTRICT_RELAYS_TO_METADATA_LISTS)
if (restrictMetadataRelaysStr != null) { if (restrictMetadataRelaysStr != null) {
this.restrictRelaysToMetadataLists = restrictMetadataRelaysStr === 'true' this.restrictRelaysToMetadataLists = restrictMetadataRelaysStr !== 'false'
setRestrictConnectionsToMetadataRelaysOnly(this.restrictRelaysToMetadataLists) setRestrictConnectionsToMetadataRelaysOnly(this.restrictRelaysToMetadataLists)
} }
} }

Loading…
Cancel
Save