Browse Source

fix relays

imwald
Silberengel 2 weeks ago
parent
commit
e50b27359b
  1. 6
      nip66-cron/index.mjs
  2. 5
      src/components/Embedded/EmbeddedNote.tsx
  3. 41
      src/components/MetadataRelaysOnlySetting/index.tsx
  4. 11
      src/constants.ts
  5. 1
      src/hooks/index.tsx
  6. 13
      src/hooks/useBypassMetadataRelaysOnlyPolicy.ts
  7. 15
      src/hooks/useRelayPageFeedPolicy.ts
  8. 3
      src/i18n/locales/cs.ts
  9. 3
      src/i18n/locales/de.ts
  10. 3
      src/i18n/locales/en.ts
  11. 3
      src/i18n/locales/es.ts
  12. 3
      src/i18n/locales/fr.ts
  13. 3
      src/i18n/locales/nl.ts
  14. 3
      src/i18n/locales/pl.ts
  15. 3
      src/i18n/locales/ru.ts
  16. 3
      src/i18n/locales/tr.ts
  17. 3
      src/i18n/locales/zh.ts
  18. 4
      src/lib/account-list-relay-urls.ts
  19. 2
      src/lib/favorites-feed-relays.ts
  20. 7
      src/lib/metadata-policy-curated-relays.test.ts
  21. 31
      src/lib/metadata-policy-curated-relays.ts
  22. 63
      src/lib/read-only-relay-personal.test.ts
  23. 68
      src/lib/read-only-relay-personal.ts
  24. 50
      src/lib/relay-pool-idle.test.ts
  25. 45
      src/lib/relay-pool-idle.ts
  26. 2
      src/pages/primary/ExplorePage/index.tsx
  27. 2
      src/pages/primary/SearchPage/index.tsx
  28. 2
      src/pages/secondary/RelayReviewsPage/index.tsx
  29. 4
      src/pages/secondary/RelaySettingsPage/index.tsx
  30. 2
      src/pages/secondary/SearchPage/index.tsx
  31. 5
      src/providers/FeedProvider.test.ts
  32. 11
      src/providers/FeedProvider.tsx
  33. 5
      src/providers/NostrProvider/index.tsx
  34. 19
      src/services/client-query.service.ts
  35. 138
      src/services/client.service.ts
  36. 27
      src/services/local-storage.service.ts

6
nip66-cron/index.mjs

@ -328,8 +328,9 @@ async function publishEvent (relayUrls, event) {
let ok = 0 let ok = 0
const conns = [] const conns = []
for (const url of relayUrls) { for (const url of relayUrls) {
let ws
try { try {
const ws = new WebSocket(url, { handshakeTimeout: 8000 }) ws = new WebSocket(url, { handshakeTimeout: 8000 })
await new Promise((resolve, reject) => { await new Promise((resolve, reject) => {
let timeoutId let timeoutId
let resolved = false let resolved = false
@ -374,6 +375,9 @@ async function publishEvent (relayUrls, event) {
}) })
} catch (err) { } catch (err) {
log('Publish relay error', { url, err: err.message }) log('Publish relay error', { url, err: err.message })
if (ws) {
try { ws.close() } catch (_) {}
}
} }
} }
for (const ws of conns) { for (const ws of conns) {

5
src/components/Embedded/EmbeddedNote.tsx

@ -23,8 +23,6 @@ import {
urlsForViewerNostrLandAggrEligibilitySync urlsForViewerNostrLandAggrEligibilitySync
} from '@/lib/nostr-land-relay-eligibility' } from '@/lib/nostr-land-relay-eligibility'
import { import {
enterMetadataRelaysOnlyBypass,
leaveMetadataRelaysOnlyBypass,
sanitizeRelayUrlsForFetch sanitizeRelayUrlsForFetch
} from '@/lib/read-only-relay-personal' } from '@/lib/read-only-relay-personal'
import { useFavoriteRelays } from '@/providers/favorite-relays-context' import { useFavoriteRelays } from '@/providers/favorite-relays-context'
@ -305,7 +303,6 @@ function EmbeddedNoteFetched({
containingEventRef.current = containingEvent containingEventRef.current = containingEvent
useEffect(() => { useEffect(() => {
enterMetadataRelaysOnlyBypass()
let cancelled = false let cancelled = false
const noteKey = noteId.trim() const noteKey = noteId.trim()
embedNoteKeyRef.current = noteKey embedNoteKeyRef.current = noteKey
@ -386,7 +383,6 @@ function EmbeddedNoteFetched({
if (eventRef.current) { if (eventRef.current) {
return () => { return () => {
cancelled = true cancelled = true
leaveMetadataRelaysOnlyBypass()
if (retryIntervalRef.current) { if (retryIntervalRef.current) {
clearInterval(retryIntervalRef.current) clearInterval(retryIntervalRef.current)
retryIntervalRef.current = null retryIntervalRef.current = null
@ -406,7 +402,6 @@ function EmbeddedNoteFetched({
return () => { return () => {
cancelled = true cancelled = true
leaveMetadataRelaysOnlyBypass()
if (retryIntervalRef.current) { if (retryIntervalRef.current) {
clearInterval(retryIntervalRef.current) clearInterval(retryIntervalRef.current)
retryIntervalRef.current = null retryIntervalRef.current = null

41
src/components/MetadataRelaysOnlySetting/index.tsx

@ -1,41 +0,0 @@
import { Label } from '@/components/ui/label'
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 storage from '@/services/local-storage.service'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
export default function MetadataRelaysOnlySetting() {
const { t } = useTranslation()
const [enabled, setEnabled] = useState(false)
useEffect(() => {
const on = storage.getRestrictRelaysToMetadataLists()
setEnabled(on)
setRestrictConnectionsToMetadataRelaysOnly(on)
}, [])
const onChange = (checked: boolean) => {
setEnabled(checked)
storage.setRestrictRelaysToMetadataLists(checked)
setRestrictConnectionsToMetadataRelaysOnly(checked)
client.interruptBackgroundQueries({ closePooledRelayConnections: true })
client.closeMetadataPolicyDisallowedRelayConnections()
window.dispatchEvent(new CustomEvent(METADATA_RELAYS_ONLY_POLICY_CHANGED_EVENT))
}
return (
<div className="space-y-2 rounded-lg border border-border p-4">
<div className="flex items-center space-x-2">
<Label htmlFor="metadata-relays-only">{t('Only my relay lists')}</Label>
<Switch id="metadata-relays-only" checked={enabled} onCheckedChange={onChange} />
</div>
<div className="text-muted-foreground text-xs max-w-xl">
{t(
'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>
)
}

11
src/constants.ts

@ -388,7 +388,7 @@ export const StorageKey = {
SHOW_RSS_FEED: 'showRssFeed', SHOW_RSS_FEED: 'showRssFeed',
PANE_MODE: 'paneMode', PANE_MODE: 'paneMode',
ADD_RANDOM_RELAYS_TO_PUBLISH: 'addRandomRelaysToPublish', ADD_RANDOM_RELAYS_TO_PUBLISH: 'addRandomRelaysToPublish',
/** When `'true'`, only connect to relays on the viewer's NIP-65 / favorites / cache / HTTP lists. */ /** @deprecated Removed — personal-relay read policy is always on when logged in. */
RESTRICT_RELAYS_TO_METADATA_LISTS: 'restrictRelaysToMetadataLists', RESTRICT_RELAYS_TO_METADATA_LISTS: 'restrictRelaysToMetadataLists',
/** When `'true'`, show Sonner toasts after successful publishes (default off). */ /** When `'true'`, show Sonner toasts after successful publishes (default off). */
SHOW_PUBLISH_SUCCESS_TOASTS: 'showPublishSuccessToasts', SHOW_PUBLISH_SUCCESS_TOASTS: 'showPublishSuccessToasts',
@ -529,11 +529,8 @@ export const MONERO_NOSTR_RELAY_URLS = [
/** Relays used for NIP-94 file metadata (kind 1063) / GIF discovery and publish. /** Relays used for NIP-94 file metadata (kind 1063) / GIF discovery and publish.
* Publish to all of these so GIFs are discoverable across clients; some may be temporarily down. */ * Publish to all of these so GIFs are discoverable across clients; some may be temporarily down. */
export const GIF_RELAY_URLS = [ export const GIF_RELAY_URLS = [
'wss://relay.damus.io',
'wss://relay.primal.net',
'wss://thecitadel.nostr1.com', 'wss://thecitadel.nostr1.com',
'wss://nos.lol', 'wss://gifbuddy.lol'
'wss://nostr.mom'
] ]
export const SEARCHABLE_RELAY_URLS = [ export const SEARCHABLE_RELAY_URLS = [
@ -552,8 +549,8 @@ export const SEARCH_QUERY_DEBOUNCE_MS = 550
export const PROFILE_RELAY_URLS = [ export const PROFILE_RELAY_URLS = [
'wss://profiles.nostr1.com', 'wss://profiles.nostr1.com',
'wss://profiles.nostrver.se/', 'wss://profiles.nostrver.se/',
'wss://indexer.coracle.social/', 'wss://thecitadel.nostr1.com',
'wss://thecitadel.nostr1.com' 'wss://indexer.coracle.social/'
] ]
export const FOLLOWS_HISTORY_RELAY_URLS = [ export const FOLLOWS_HISTORY_RELAY_URLS = [

1
src/hooks/index.tsx

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

13
src/hooks/useBypassMetadataRelaysOnlyPolicy.ts

@ -1,13 +0,0 @@
import {
enterMetadataRelaysOnlyBypass,
leaveMetadataRelaysOnlyBypass
} from '@/lib/read-only-relay-personal'
import { useEffect } from 'react'
/** Disable “only my relay lists” while mounted (relay explore, search, relay directory). */
export function useBypassMetadataRelaysOnlyPolicy(): void {
useEffect(() => {
enterMetadataRelaysOnlyBypass()
return () => leaveMetadataRelaysOnlyBypass()
}, [])
}

15
src/hooks/useRelayPageFeedPolicy.ts

@ -1,19 +1,10 @@
import { import { enterSingleRelayExplicitBrowse, leaveSingleRelayExplicitBrowse } from '@/lib/read-only-relay-personal'
enterMetadataRelaysOnlyBypass,
enterSingleRelayExplicitBrowse,
leaveMetadataRelaysOnlyBypass,
leaveSingleRelayExplicitBrowse
} from '@/lib/read-only-relay-personal'
import { useEffect } from 'react' import { useEffect } from 'react'
/** Relay detail feed: bypass metadata-only narrowing, user blocks, and session strikes for the page relay. */ /** Relay detail feed: connect to the page relay even if it is not on the viewer's personal lists. */
export function useRelayPageFeedPolicy(): void { export function useRelayPageFeedPolicy(): void {
useEffect(() => { useEffect(() => {
enterMetadataRelaysOnlyBypass()
enterSingleRelayExplicitBrowse() enterSingleRelayExplicitBrowse()
return () => { return () => leaveSingleRelayExplicitBrowse()
leaveSingleRelayExplicitBrowse()
leaveMetadataRelaysOnlyBypass()
}
}, []) }, [])
} }

3
src/i18n/locales/cs.ts

@ -115,9 +115,6 @@ export default {
'Follows you': 'Follows you', 'Follows you': 'Follows you',
'Relay Settings': 'Relays and Storage Settings', 'Relay Settings': 'Relays and Storage Settings',
'Relays and Storage Settings': 'Relays and Storage Settings', 'Relays and Storage Settings': 'Relays and Storage Settings',
'Only my relay lists': 'Only my relay lists',
'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, read connections stay on your relay lists plus the built-in profile index relays (profiles.nostr1.com, relay.damus.io, etc.). Other relays used for feeds, threads, or search are not contacted unless listed. Publishing is unchanged. Relay explore and Search pages are exempt.',
'Relay set name': 'Relay set name', 'Relay set name': 'Relay set name',
'Add a new relay set': 'Add a new relay set', 'Add a new relay set': 'Add a new relay set',
Add: 'Add', Add: 'Add',

3
src/i18n/locales/de.ts

@ -116,9 +116,6 @@ export default {
'Follows you': 'Folgt dir', 'Follows you': 'Folgt dir',
'Relay Settings': 'Relay-Einstellungen', 'Relay Settings': 'Relay-Einstellungen',
'Relays and Storage Settings': 'Relays und Speicher', 'Relays and Storage Settings': 'Relays und Speicher',
'Only my relay lists': 'Nur meine Relay-Listen',
'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.':
'Wenn aktiv, bleiben Lese-Verbindungen auf deinen Listen plus den eingebauten Profilindex-Relays (profiles.nostr1.com, relay.damus.io, …). Andere Relays für Feeds, Threads oder Suche werden nur bei Listeneintrag genutzt. Veröffentlichen bleibt unverändert. Relay-Entdecken und Suche sind ausgenommen.',
'Relay set name': 'Relay-Set Name', 'Relay set name': 'Relay-Set Name',
'Add a new relay set': 'Neues Relay-Set hinzufügen', 'Add a new relay set': 'Neues Relay-Set hinzufügen',
Add: 'Hinzufügen', Add: 'Hinzufügen',

3
src/i18n/locales/en.ts

@ -113,9 +113,6 @@ export default {
'Follows you': 'Follows you', 'Follows you': 'Follows you',
'Relay Settings': 'Relays and Storage Settings', 'Relay Settings': 'Relays and Storage Settings',
'Relays and Storage Settings': 'Relays and Storage Settings', 'Relays and Storage Settings': 'Relays and Storage Settings',
'Only my relay lists': 'Only my relay lists',
'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, read connections stay on your relay lists plus the built-in profile index relays (profiles.nostr1.com, relay.damus.io, etc.). Other relays used for feeds, threads, or search are not contacted unless listed. Publishing is unchanged. Relay explore and Search pages are exempt.',
'Relay set name': 'Relay set name', 'Relay set name': 'Relay set name',
'Add a new relay set': 'Add a new relay set', 'Add a new relay set': 'Add a new relay set',
Add: 'Add', Add: 'Add',

3
src/i18n/locales/es.ts

@ -115,9 +115,6 @@ export default {
'Follows you': 'Te sigue', 'Follows you': 'Te sigue',
'Relay Settings': 'Configuración de relés', 'Relay Settings': 'Configuración de relés',
'Relays and Storage Settings': 'Relays and Storage Settings', 'Relays and Storage Settings': 'Relays and Storage Settings',
'Only my relay lists': 'Only my relay lists',
'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, read connections stay on your relay lists plus the built-in profile index relays (profiles.nostr1.com, relay.damus.io, etc.). Other relays used for feeds, threads, or search are not contacted unless listed. Publishing is unchanged. Relay explore and Search pages are exempt.',
'Relay set name': 'Nombre del conjunto de relés', 'Relay set name': 'Nombre del conjunto de relés',
'Add a new relay set': 'Agregar un nuevo conjunto de relés', 'Add a new relay set': 'Agregar un nuevo conjunto de relés',
Add: 'Agregar', Add: 'Agregar',

3
src/i18n/locales/fr.ts

@ -115,9 +115,6 @@ export default {
'Follows you': 'Vous suit', 'Follows you': 'Vous suit',
'Relay Settings': 'Paramètres des relais', 'Relay Settings': 'Paramètres des relais',
'Relays and Storage Settings': 'Relays and Storage Settings', 'Relays and Storage Settings': 'Relays and Storage Settings',
'Only my relay lists': 'Only my relay lists',
'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, read connections stay on your relay lists plus the built-in profile index relays (profiles.nostr1.com, relay.damus.io, etc.). Other relays used for feeds, threads, or search are not contacted unless listed. Publishing is unchanged. Relay explore and Search pages are exempt.',
'Relay set name': 'Nom du groupe de relais', 'Relay set name': 'Nom du groupe de relais',
'Add a new relay set': 'Ajouter un nouveau groupe de relais', 'Add a new relay set': 'Ajouter un nouveau groupe de relais',
Add: 'Ajouter', Add: 'Ajouter',

3
src/i18n/locales/nl.ts

@ -115,9 +115,6 @@ export default {
'Follows you': 'Follows you', 'Follows you': 'Follows you',
'Relay Settings': 'Relays and Storage Settings', 'Relay Settings': 'Relays and Storage Settings',
'Relays and Storage Settings': 'Relays and Storage Settings', 'Relays and Storage Settings': 'Relays and Storage Settings',
'Only my relay lists': 'Only my relay lists',
'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, read connections stay on your relay lists plus the built-in profile index relays (profiles.nostr1.com, relay.damus.io, etc.). Other relays used for feeds, threads, or search are not contacted unless listed. Publishing is unchanged. Relay explore and Search pages are exempt.',
'Relay set name': 'Relay set name', 'Relay set name': 'Relay set name',
'Add a new relay set': 'Add a new relay set', 'Add a new relay set': 'Add a new relay set',
Add: 'Add', Add: 'Add',

3
src/i18n/locales/pl.ts

@ -115,9 +115,6 @@ export default {
'Follows you': 'Obserwujący', 'Follows you': 'Obserwujący',
'Relay Settings': 'Ustawienia transmiterów', 'Relay Settings': 'Ustawienia transmiterów',
'Relays and Storage Settings': 'Relays and Storage Settings', 'Relays and Storage Settings': 'Relays and Storage Settings',
'Only my relay lists': 'Only my relay lists',
'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, read connections stay on your relay lists plus the built-in profile index relays (profiles.nostr1.com, relay.damus.io, etc.). Other relays used for feeds, threads, or search are not contacted unless listed. Publishing is unchanged. Relay explore and Search pages are exempt.',
'Relay set name': 'Wpisz nazwę grupy', 'Relay set name': 'Wpisz nazwę grupy',
'Add a new relay set': 'Utwórz grupę transmiterów', 'Add a new relay set': 'Utwórz grupę transmiterów',
Add: 'Dodaj', Add: 'Dodaj',

3
src/i18n/locales/ru.ts

@ -115,9 +115,6 @@ export default {
'Follows you': 'Подписан на вас', 'Follows you': 'Подписан на вас',
'Relay Settings': 'Настройки ретрансляторов', 'Relay Settings': 'Настройки ретрансляторов',
'Relays and Storage Settings': 'Relays and Storage Settings', 'Relays and Storage Settings': 'Relays and Storage Settings',
'Only my relay lists': 'Only my relay lists',
'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, read connections stay on your relay lists plus the built-in profile index relays (profiles.nostr1.com, relay.damus.io, etc.). Other relays used for feeds, threads, or search are not contacted unless listed. Publishing is unchanged. Relay explore and Search pages are exempt.',
'Relay set name': 'Имя набора ретрансляторов', 'Relay set name': 'Имя набора ретрансляторов',
'Add a new relay set': 'Добавить новый набор ретрансляторов', 'Add a new relay set': 'Добавить новый набор ретрансляторов',
Add: 'Добавить', Add: 'Добавить',

3
src/i18n/locales/tr.ts

@ -115,9 +115,6 @@ export default {
'Follows you': 'Follows you', 'Follows you': 'Follows you',
'Relay Settings': 'Relays and Storage Settings', 'Relay Settings': 'Relays and Storage Settings',
'Relays and Storage Settings': 'Relays and Storage Settings', 'Relays and Storage Settings': 'Relays and Storage Settings',
'Only my relay lists': 'Only my relay lists',
'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, read connections stay on your relay lists plus the built-in profile index relays (profiles.nostr1.com, relay.damus.io, etc.). Other relays used for feeds, threads, or search are not contacted unless listed. Publishing is unchanged. Relay explore and Search pages are exempt.',
'Relay set name': 'Relay set name', 'Relay set name': 'Relay set name',
'Add a new relay set': 'Add a new relay set', 'Add a new relay set': 'Add a new relay set',
Add: 'Add', Add: 'Add',

3
src/i18n/locales/zh.ts

@ -115,9 +115,6 @@ export default {
'Follows you': '关注了你', 'Follows you': '关注了你',
'Relay Settings': '服务器设置', 'Relay Settings': '服务器设置',
'Relays and Storage Settings': 'Relays and Storage Settings', 'Relays and Storage Settings': 'Relays and Storage Settings',
'Only my relay lists': 'Only my relay lists',
'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, read connections stay on your relay lists plus the built-in profile index relays (profiles.nostr1.com, relay.damus.io, etc.). Other relays used for feeds, threads, or search are not contacted unless listed. Publishing is unchanged. Relay explore and Search pages are exempt.',
'Relay set name': '服务器组名', 'Relay set name': '服务器组名',
'Add a new relay set': '添加新的服务器组', 'Add a new relay set': '添加新的服务器组',
Add: '添加', Add: '添加',

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

@ -4,6 +4,10 @@ import { normalizeRelayUrlByScheme } from '@/lib/url'
import { collectViewerReadInboxUrls } from '@/lib/viewer-read-inboxes' import { collectViewerReadInboxUrls } from '@/lib/viewer-read-inboxes'
import { collectViewerWriteOutboxUrls } from '@/lib/viewer-write-outboxes' import { collectViewerWriteOutboxUrls } from '@/lib/viewer-write-outboxes'
import { viewerUsesGlobalRelayDefaults } from '@/lib/viewer-relay-defaults' import { viewerUsesGlobalRelayDefaults } from '@/lib/viewer-relay-defaults'
import {
viewerIncludeGlobalFastReadRelayLayer,
viewerIncludeGlobalFastWriteRelayLayer
} from '@/lib/read-only-relay-personal'
import client from '@/services/client.service' import client from '@/services/client.service'
/** /**

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

@ -20,7 +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 { viewerIncludeGlobalFastReadRelayLayer } 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'

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

@ -1,6 +1,7 @@
import { DOCUMENT_RELAY_URLS, FAST_READ_RELAY_URLS, 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 { import {
isMetadataPolicyActiveReadGrantRelay,
isMetadataPolicyCuratedRelay, isMetadataPolicyCuratedRelay,
isMetadataPolicyOperationScopedRelay isMetadataPolicyOperationScopedRelay
} from './metadata-policy-curated-relays' } from './metadata-policy-curated-relays'
@ -17,4 +18,10 @@ describe('metadata-policy-curated-relays', () => {
expect(isMetadataPolicyOperationScopedRelay(FAST_READ_RELAY_URLS[0]!)).toBe(false) expect(isMetadataPolicyOperationScopedRelay(FAST_READ_RELAY_URLS[0]!)).toBe(false)
expect(isMetadataPolicyOperationScopedRelay('wss://nostr.wirednet.jp/')).toBe(false) expect(isMetadataPolicyOperationScopedRelay('wss://nostr.wirednet.jp/')).toBe(false)
}) })
it('active read grant includes search and discovery stacks', () => {
expect(isMetadataPolicyActiveReadGrantRelay('wss://search.nos.today/')).toBe(true)
expect(isMetadataPolicyActiveReadGrantRelay(FAST_READ_RELAY_URLS[0]!)).toBe(false)
expect(isMetadataPolicyActiveReadGrantRelay('wss://nostr.wirednet.jp/')).toBe(false)
})
}) })

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

@ -43,6 +43,30 @@ function relayKeyForCuratedSet(url: string): string {
return (normalizeAnyRelayUrl(url) || url.trim()).toLowerCase() return (normalizeAnyRelayUrl(url) || url.trim()).toLowerCase()
} }
/** Relays grantable for the duration of an active read query/subscribe (not general feed widening). */
const METADATA_POLICY_ACTIVE_READ_GRANT_RELAY_LISTS: readonly (readonly string[])[] = [
...METADATA_POLICY_OPERATION_SCOPED_RELAY_LISTS,
SEARCHABLE_RELAY_URLS,
READ_ONLY_RELAY_URLS,
NIP66_DISCOVERY_RELAY_URLS
]
let activeReadGrantRelayKeySet: ReadonlySet<string> | null = null
function getActiveReadGrantRelayKeySet(): ReadonlySet<string> {
if (!activeReadGrantRelayKeySet) {
const out = new Set<string>()
for (const list of METADATA_POLICY_ACTIVE_READ_GRANT_RELAY_LISTS) {
for (const u of list) {
const key = relayKeyForCuratedSet(u)
if (key) out.add(key)
}
}
activeReadGrantRelayKeySet = out
}
return activeReadGrantRelayKeySet
}
function getCuratedRelayKeySet(): ReadonlySet<string> { function getCuratedRelayKeySet(): ReadonlySet<string> {
if (!curatedRelayKeySet) { if (!curatedRelayKeySet) {
const out = new Set<string>() const out = new Set<string>()
@ -83,6 +107,12 @@ export function isMetadataPolicyOperationScopedRelay(url: string): boolean {
return key.length > 0 && getOperationScopedRelayKeySet().has(key) return key.length > 0 && getOperationScopedRelayKeySet().has(key)
} }
/** Search / index / discovery stacks allowed only while an active read operation lists them. */
export function isMetadataPolicyActiveReadGrantRelay(url: string): boolean {
const key = relayKeyForCuratedSet(url)
return key.length > 0 && getActiveReadGrantRelayKeySet().has(key)
}
let profileRelayKeySet: ReadonlySet<string> | null = null let profileRelayKeySet: ReadonlySet<string> | null = null
function getProfileRelayKeySet(): ReadonlySet<string> { function getProfileRelayKeySet(): ReadonlySet<string> {
@ -107,5 +137,6 @@ export function isMetadataPolicyProfileRelay(url: string): boolean {
export function resetMetadataPolicyCuratedRelayKeysForTests(): void { export function resetMetadataPolicyCuratedRelayKeysForTests(): void {
curatedRelayKeySet = null curatedRelayKeySet = null
operationScopedRelayKeySet = null operationScopedRelayKeySet = null
activeReadGrantRelayKeySet = null
profileRelayKeySet = null profileRelayKeySet = null
} }

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

@ -9,19 +9,15 @@ import {
isRelayConnectionAllowedForViewer, isRelayConnectionAllowedForViewer,
resetRelayConnectionOperationScopeForTests, resetRelayConnectionOperationScopeForTests,
sanitizeRelayUrlsForFetch, sanitizeRelayUrlsForFetch,
enterMetadataRelaysOnlyBypass,
enterSingleRelayExplicitBrowse, enterSingleRelayExplicitBrowse,
enterSingleRelayExplicitFetchScope, enterSingleRelayExplicitFetchScope,
leaveMetadataRelaysOnlyBypass,
leaveSingleRelayExplicitBrowse, leaveSingleRelayExplicitBrowse,
setRestrictConnectionsToMetadataRelaysOnly,
setViewerPersonalRelayKeys setViewerPersonalRelayKeys
} from './read-only-relay-personal' } from './read-only-relay-personal'
import { setViewerBlockedRelayUrls } from './viewer-blocked-relays' import { setViewerBlockedRelayUrls } from './viewer-blocked-relays'
describe('read-only-relay-personal', () => { describe('read-only-relay-personal', () => {
beforeEach(() => { beforeEach(() => {
setRestrictConnectionsToMetadataRelaysOnly(false)
setViewerPersonalRelayKeys(new Set(), { viewerActive: false }) setViewerPersonalRelayKeys(new Set(), { viewerActive: false })
setViewerBlockedRelayUrls([]) setViewerBlockedRelayUrls([])
syncViewerRelayStackNostrLandAggrEligible([]) syncViewerRelayStackNostrLandAggrEligible([])
@ -29,9 +25,8 @@ describe('read-only-relay-personal', () => {
}) })
afterEach(() => { afterEach(() => {
setRestrictConnectionsToMetadataRelaysOnly(false)
leaveMetadataRelaysOnlyBypass()
leaveSingleRelayExplicitBrowse() leaveSingleRelayExplicitBrowse()
setViewerPersonalRelayKeys(new Set(), { viewerActive: false })
setViewerBlockedRelayUrls([]) setViewerBlockedRelayUrls([])
syncViewerRelayStackNostrLandAggrEligible([]) syncViewerRelayStackNostrLandAggrEligible([])
resetRelayConnectionOperationScopeForTests() resetRelayConnectionOperationScopeForTests()
@ -81,8 +76,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 at connect time', () => { it('personal-relay policy blocks ad-hoc feed relays at connect time', () => {
setRestrictConnectionsToMetadataRelaysOnly(true)
setViewerPersonalRelayKeys(buildPersonalRelayKeySet(['wss://relay.example.com/']), { viewerActive: true }) setViewerPersonalRelayKeys(buildPersonalRelayKeySet(['wss://relay.example.com/']), { viewerActive: true })
const urls = [ const urls = [
'wss://relay.example.com/', 'wss://relay.example.com/',
@ -90,16 +84,18 @@ 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(['wss://relay.example.com/']) expect(sanitizeRelayUrlsForFetch(urls)).toEqual([
expect(isRelayConnectionAllowedForViewer('wss://profiles.nostr1.com/')).toBe(false) 'wss://relay.example.com/',
expect(isRelayConnectionAllowedForViewer('wss://thecitadel.nostr1.com/')).toBe(false) 'wss://profiles.nostr1.com/'
])
expect(isRelayConnectionAllowedForViewer('wss://profiles.nostr1.com/')).toBe(true)
expect(isRelayConnectionAllowedForViewer('wss://thecitadel.nostr1.com/')).toBe(true)
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)
}) })
it('metadata-only policy still allows aggr when viewer lists wss://nostr.land', () => { it('personal-relay policy still allows aggr when viewer lists wss://nostr.land', () => {
setRestrictConnectionsToMetadataRelaysOnly(true)
syncViewerRelayStackNostrLandAggrEligible(['wss://nostr.land/']) syncViewerRelayStackNostrLandAggrEligible(['wss://nostr.land/'])
setViewerPersonalRelayKeys(buildPersonalRelayKeySet(['wss://nostr.land/']), { viewerActive: true }) setViewerPersonalRelayKeys(buildPersonalRelayKeySet(['wss://nostr.land/']), { viewerActive: true })
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/']
@ -112,33 +108,30 @@ describe('read-only-relay-personal', () => {
}) })
it('operation scope allows document and gif constant relays during fetch', () => { it('operation scope allows document and gif constant relays during fetch', () => {
setRestrictConnectionsToMetadataRelaysOnly(true)
setViewerPersonalRelayKeys(new Set(), { viewerActive: true }) setViewerPersonalRelayKeys(new Set(), { viewerActive: true })
expect(isRelayConnectionAllowedForViewer('wss://thecitadel.nostr1.com/')).toBe(false) expect(isRelayConnectionAllowedForViewer('wss://theforest.nostr1.com/')).toBe(false)
expect(isRelayConnectionAllowedForViewer('wss://nostr.wine/')).toBe(false) expect(isRelayConnectionAllowedForViewer('wss://nostr.wirednet.jp/')).toBe(false)
const revoke = grantRelayConnectionOperationScope([ const revoke = grantRelayConnectionOperationScope([
'wss://thecitadel.nostr1.com/', 'wss://thecitadel.nostr1.com/',
'wss://nostr.wine/', 'wss://nostr.wirednet.jp/',
'wss://essayist.decentnewsroom.com/' '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://nostr.wirednet.jp/')).toBe(false)
expect(isRelayConnectionAllowedForViewer('wss://essayist.decentnewsroom.com/')).toBe(true) expect(isRelayConnectionAllowedForViewer('wss://essayist.decentnewsroom.com/')).toBe(true)
revoke() revoke()
}) })
it('metadata-only policy allows curated relays only during an operation scope', () => { it('personal-relay policy allows document relays only during an operation scope', () => {
setRestrictConnectionsToMetadataRelaysOnly(true)
setViewerPersonalRelayKeys(new Set(), { viewerActive: true }) setViewerPersonalRelayKeys(new Set(), { viewerActive: true })
expect(isRelayConnectionAllowedForViewer('wss://profiles.nostr1.com/')).toBe(false) expect(isRelayConnectionAllowedForViewer('wss://essayist.decentnewsroom.com/')).toBe(false)
const revoke = grantRelayConnectionOperationScope(['wss://profiles.nostr1.com/']) const revoke = grantRelayConnectionOperationScope(['wss://essayist.decentnewsroom.com/'])
expect(isRelayConnectionAllowedForViewer('wss://profiles.nostr1.com/')).toBe(true) expect(isRelayConnectionAllowedForViewer('wss://essayist.decentnewsroom.com/')).toBe(true)
revoke() revoke()
expect(isRelayConnectionAllowedForViewer('wss://profiles.nostr1.com/')).toBe(false) expect(isRelayConnectionAllowedForViewer('wss://essayist.decentnewsroom.com/')).toBe(false)
}) })
it('metadata-only policy allows viewer cache and HTTP index relays', () => { it('personal-relay policy allows viewer cache and HTTP index relays', () => {
setRestrictConnectionsToMetadataRelaysOnly(true)
setViewerPersonalRelayKeys( setViewerPersonalRelayKeys(
buildPersonalRelayKeySet([ buildPersonalRelayKeySet([
'ws://localhost:4869/', 'ws://localhost:4869/',
@ -152,18 +145,15 @@ describe('read-only-relay-personal', () => {
expect(isRelayConnectionAllowedForViewer('wss://theforest.nostr1.com/')).toBe(false) expect(isRelayConnectionAllowedForViewer('wss://theforest.nostr1.com/')).toBe(false)
}) })
it('metadata-only bypass allows relays outside personal lists', () => { it('personal-relay policy allows profile index relays without operation scope', () => {
setRestrictConnectionsToMetadataRelaysOnly(true) setViewerPersonalRelayKeys(new Set(), { viewerActive: true })
setViewerPersonalRelayKeys(buildPersonalRelayKeySet(['wss://nostr.land/']), { viewerActive: true }) expect(isRelayConnectionAllowedForViewer('wss://profiles.nostr1.com/')).toBe(true)
enterMetadataRelaysOnlyBypass() expect(sanitizeRelayUrlsForFetch(['wss://profiles.nostr1.com/', 'wss://nostr.wirednet.jp/'])).toEqual([
const urls = ['wss://nostr.land/', 'wss://relay.damus.io/'] 'wss://profiles.nostr1.com/'
expect(sanitizeRelayUrlsForFetch(urls)).toEqual(urls) ])
expect(isRelayConnectionAllowedForViewer('wss://relay.damus.io/')).toBe(true)
leaveMetadataRelaysOnlyBypass()
}) })
it('explicit single-relay browse keeps user-blocked and non-list relays', () => { it('explicit single-relay browse keeps user-blocked and non-list relays', () => {
setRestrictConnectionsToMetadataRelaysOnly(true)
setViewerPersonalRelayKeys(buildPersonalRelayKeySet(['wss://nostr.land/']), { viewerActive: true }) setViewerPersonalRelayKeys(buildPersonalRelayKeySet(['wss://nostr.land/']), { viewerActive: true })
setViewerBlockedRelayUrls(['wss://relay.layer.systems/']) setViewerBlockedRelayUrls(['wss://relay.layer.systems/'])
enterSingleRelayExplicitBrowse() enterSingleRelayExplicitBrowse()
@ -173,8 +163,7 @@ describe('read-only-relay-personal', () => {
leaveSingleRelayExplicitBrowse() leaveSingleRelayExplicitBrowse()
}) })
it('operation scope grants an explicit single-relay target under metadata-only', () => { it('operation scope grants an explicit single-relay target under personal-relay policy', () => {
setRestrictConnectionsToMetadataRelaysOnly(true)
setViewerPersonalRelayKeys(new Set(), { viewerActive: true }) setViewerPersonalRelayKeys(new Set(), { viewerActive: true })
const leaveFetchScope = enterSingleRelayExplicitFetchScope() const leaveFetchScope = enterSingleRelayExplicitFetchScope()
const target = 'wss://relay.layer.systems/' const target = 'wss://relay.layer.systems/'

68
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 { isMetadataPolicyOperationScopedRelay } from '@/lib/metadata-policy-curated-relays' import { isMetadataPolicyActiveReadGrantRelay, isMetadataPolicyProfileRelay } from '@/lib/metadata-policy-curated-relays'
import { import {
filterAggrNostrLandUnlessViewerEligible, filterAggrNostrLandUnlessViewerEligible,
getViewerRelayStackNostrLandAggrEligible, getViewerRelayStackNostrLandAggrEligible,
@ -20,39 +20,13 @@ const personalListRequiredKeySet = new Set(
let viewerPersonalRelayKeys = new Set<string>() let viewerPersonalRelayKeys = new Set<string>()
/** True after a logged-in viewer's personal relay keys were synced (including empty lists). */ /** True after a logged-in viewer's personal relay keys were synced (including empty lists). */
let viewerMetadataRelaysPolicyActive = false let viewerMetadataRelaysPolicyActive = false
let restrictConnectionsToMetadataRelaysOnly = false
/** Relay explore / search UI: metadata-only policy must not narrow relays on those pages. */
let metadataRelaysOnlyBypassDepth = 0
/** Relay detail page mounted: explicit single-relay browse must not be blocked by strikes / user blocks / list gates. */ /** Relay detail page mounted: explicit single-relay browse must not be blocked by strikes / user blocks / list gates. */
let singleRelayExplicitBrowseDepth = 0 let singleRelayExplicitBrowseDepth = 0
/** In-flight authoritative single-relay timeline REQ (see {@link enterSingleRelayExplicitFetchScope}). */ /** In-flight authoritative single-relay timeline REQ (see {@link enterSingleRelayExplicitFetchScope}). */
let singleRelayExplicitFetchDepth = 0 let singleRelayExplicitFetchDepth = 0
/** In-flight query/subscribe URLs (constants + caller stack) allowed to connect briefly under metadata-only policy. */ /** In-flight query/subscribe URLs allowed to connect briefly under the personal-relay read policy. */
const operationScopedRelayKeys = new Set<string>() 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 {
restrictConnectionsToMetadataRelaysOnly = enabled
}
export function isRestrictConnectionsToMetadataRelaysOnly(): boolean {
return restrictConnectionsToMetadataRelaysOnly
}
export function enterMetadataRelaysOnlyBypass(): void {
metadataRelaysOnlyBypassDepth++
}
export function leaveMetadataRelaysOnlyBypass(): void {
metadataRelaysOnlyBypassDepth = Math.max(0, metadataRelaysOnlyBypassDepth - 1)
}
export function isMetadataRelaysOnlyBypassActive(): boolean {
return metadataRelaysOnlyBypassDepth > 0
}
export function enterSingleRelayExplicitBrowse(): void { export function enterSingleRelayExplicitBrowse(): void {
singleRelayExplicitBrowseDepth++ singleRelayExplicitBrowseDepth++
} }
@ -91,13 +65,12 @@ function shouldPreserveExplicitSingleRelay(
return isSingleRelayExplicitPolicyActive() return isSingleRelayExplicitPolicyActive()
} }
/** Logged-in viewer with metadata-only mode: only connect reads to the viewer's relay lists. */ /**
* Logged-in viewer: only connect reads to personal relay lists, aggr when eligible,
* operation-scoped URLs, and explicit single-relay browse.
*/
export function isMetadataRelaysOnlyPolicyActive(): boolean { export function isMetadataRelaysOnlyPolicyActive(): boolean {
return ( return viewerMetadataRelaysPolicyActive
restrictConnectionsToMetadataRelaysOnly &&
viewerMetadataRelaysPolicyActive &&
!isMetadataRelaysOnlyBypassActive()
)
} }
export function isRelayUrlInViewerMetadataLists(url: string): boolean { export function isRelayUrlInViewerMetadataLists(url: string): boolean {
@ -106,12 +79,13 @@ export function isRelayUrlInViewerMetadataLists(url: string): boolean {
} }
/** /**
* Under metadata-only policy: viewer NIP-65 / favorites / cache / HTTP lists, plus aggr.nostr.land when * Under personal-relay read policy: viewer NIP-65 / favorites / cache / HTTP lists, aggr.nostr.land when
* wss://nostr.land is listed, plus relays in an active {@link grantRelayConnectionOperationScope}. * wss://nostr.land is listed, {@link PROFILE_RELAY_URLS}, plus relays in an active operation scope.
*/ */
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) const key = relayUrlKey(url)
if (key.length > 0 && operationScopedRelayKeys.has(key)) return true if (key.length > 0 && operationScopedRelayKeys.has(key)) return true
return false return false
@ -119,21 +93,17 @@ export function isRelayAllowedUnderMetadataOnlyPolicy(url: string): boolean {
/** /**
* Allow read connects to non-personal relays only for the lifetime of an in-flight query/subscribe. * 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 * Call with the caller's intended URL list **before** {@link sanitizeRelayUrlsForFetch}.
* (document / GIF / profile stacks not FAST_READ or feed widening).
*/ */
export function grantRelayConnectionOperationScope(urls: readonly string[]): () => void { export function grantRelayConnectionOperationScope(urls: readonly string[]): () => void {
if (!isMetadataRelaysOnlyPolicyActive()) return () => {} if (!isMetadataRelaysOnlyPolicyActive()) return () => {}
const singleExplicit = urls.length === 1 && isSingleRelayExplicitPolicyActive()
const added: string[] = [] const added: string[] = []
for (const raw of urls) { for (const raw of urls) {
if (isRelayUrlInViewerMetadataLists(raw)) continue if (isRelayUrlInViewerMetadataLists(raw)) continue
if (getViewerRelayStackNostrLandAggrEligible() && relayUrlIsAggrNostrLand(raw)) continue if (getViewerRelayStackNostrLandAggrEligible() && relayUrlIsAggrNostrLand(raw)) continue
if ( if (isMetadataPolicyProfileRelay(raw)) continue
!isMetadataPolicyOperationScopedRelay(raw) && if (!isMetadataPolicyActiveReadGrantRelay(raw) && !singleExplicit) continue
!(urls.length === 1 && isSingleRelayExplicitPolicyActive())
) {
continue
}
const key = relayUrlKey(raw) const key = relayUrlKey(raw)
if (!key || operationScopedRelayKeys.has(key)) continue if (!key || operationScopedRelayKeys.has(key)) continue
operationScopedRelayKeys.add(key) operationScopedRelayKeys.add(key)
@ -149,7 +119,7 @@ export function resetRelayConnectionOperationScopeForTests(): void {
operationScopedRelayKeys.clear() 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 personal-relay policy is on. */
export function isRelayConnectionAllowedForViewer(url: string): boolean { export function isRelayConnectionAllowedForViewer(url: string): boolean {
if (isSingleRelayExplicitPolicyActive()) return true if (isSingleRelayExplicitPolicyActive()) return true
if (!isMetadataRelaysOnlyPolicyActive()) return true if (!isMetadataRelaysOnlyPolicyActive()) return true
@ -207,7 +177,7 @@ 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. */ /** Under personal-relay policy: viewer lists + aggr + profile index + in-flight operation scope. */
function filterRelayUrlsToMetadataOnlyPersonalLists( function filterRelayUrlsToMetadataOnlyPersonalLists(
urls: readonly string[], urls: readonly string[],
personalKeys: ReadonlySet<string> personalKeys: ReadonlySet<string>
@ -216,16 +186,18 @@ function filterRelayUrlsToMetadataOnlyPersonalLists(
const key = relayUrlKey(u) const key = relayUrlKey(u)
if (key.length > 0 && personalKeys.has(key)) return true if (key.length > 0 && personalKeys.has(key)) return true
if (getViewerRelayStackNostrLandAggrEligible() && relayUrlIsAggrNostrLand(u)) return true if (getViewerRelayStackNostrLandAggrEligible() && relayUrlIsAggrNostrLand(u)) return true
if (isMetadataPolicyProfileRelay(u)) return true
if (key.length > 0 && operationScopedRelayKeys.has(key)) return true
return false return false
}) })
} }
/** When metadata-only is on, omit global FAST_READ / trending widening from REQ stacks. */ /** Omit global FAST_READ / trending widening from REQ stacks under personal-relay policy. */
export function viewerIncludeGlobalFastReadRelayLayer(): boolean { export function viewerIncludeGlobalFastReadRelayLayer(): boolean {
return !isMetadataRelaysOnlyPolicyActive() return !isMetadataRelaysOnlyPolicyActive()
} }
/** When metadata-only is on, omit {@link FAST_WRITE_RELAY_URLS} from read-side merge/fetch stacks (publish unchanged). */ /** Omit {@link FAST_WRITE_RELAY_URLS} from read-side merge/fetch stacks (publish unchanged). */
export function viewerIncludeGlobalFastWriteRelayLayer(): boolean { export function viewerIncludeGlobalFastWriteRelayLayer(): boolean {
return !isMetadataRelaysOnlyPolicyActive() return !isMetadataRelaysOnlyPolicyActive()
} }

50
src/lib/relay-pool-idle.test.ts

@ -0,0 +1,50 @@
import { describe, expect, it, beforeEach, afterEach } from 'vitest'
import { PROFILE_RELAY_URLS } from '@/constants'
import { setViewerPersonalRelayKeys } from '@/lib/read-only-relay-personal'
import {
closePublishTransientRelaySockets,
initRelayPoolIdle,
resetRelayPoolIdleForTests
} from './relay-pool-idle'
describe('relay-pool-idle publish cleanup', () => {
const closed: string[] = []
const pool = {
listConnectionStatus: () =>
new Map([
['wss://author-outbox.example/', true],
['wss://relay.example.com/', true],
[PROFILE_RELAY_URLS[0]!, true]
]),
close: (urls: string[]) => {
closed.push(...urls)
}
}
beforeEach(() => {
closed.length = 0
setViewerPersonalRelayKeys(new Set(['wss://relay.example.com/']), { viewerActive: true })
initRelayPoolIdle(pool as never, () => false)
})
afterEach(() => {
resetRelayPoolIdleForTests()
setViewerPersonalRelayKeys(new Set(), { viewerActive: false })
})
it('closes author/random publish relays but keeps personal and profile index', () => {
closePublishTransientRelaySockets([
'wss://author-outbox.example/',
'wss://relay.example.com/',
PROFILE_RELAY_URLS[0]!
])
expect(closed).toEqual(['wss://author-outbox.example/'])
})
it('skips relays that still have active subscriptions', () => {
resetRelayPoolIdleForTests()
initRelayPoolIdle(pool as never, (key) => key.includes('author-outbox'))
closePublishTransientRelaySockets(['wss://author-outbox.example/'])
expect(closed).toEqual([])
})
})

45
src/lib/relay-pool-idle.ts

@ -1,4 +1,6 @@
import { RELAY_POOL_IDLE_SWEEP_INTERVAL_MS, RELAY_POOL_SOCKET_IDLE_MS } from '@/constants' import { RELAY_POOL_IDLE_SWEEP_INTERVAL_MS, RELAY_POOL_SOCKET_IDLE_MS } from '@/constants'
import { isMetadataPolicyProfileRelay } from '@/lib/metadata-policy-curated-relays'
import { isRelayUrlInViewerMetadataLists } from '@/lib/read-only-relay-personal'
import logger from '@/lib/logger' import logger from '@/lib/logger'
import { canonicalRelaySessionKey, normalizeAnyRelayUrl } from '@/lib/url' import { canonicalRelaySessionKey, normalizeAnyRelayUrl } from '@/lib/url'
import type { SimplePool } from 'nostr-tools' import type { SimplePool } from 'nostr-tools'
@ -14,6 +16,10 @@ function canon(url: string): string {
return canonicalRelaySessionKey(normalizeAnyRelayUrl(url) || url.trim()) return canonicalRelaySessionKey(normalizeAnyRelayUrl(url) || url.trim())
} }
function shouldKeepProfileRelaySocketOpen(url: string): boolean {
return isMetadataPolicyProfileRelay(url)
}
/** Mark relay URL as recently used (connect, REQ, publish). */ /** Mark relay URL as recently used (connect, REQ, publish). */
export function touchRelayPoolActivity(url: string): void { export function touchRelayPoolActivity(url: string): void {
const key = canon(url) const key = canon(url)
@ -49,6 +55,7 @@ export function sweepIdleRelayPoolSockets(): void {
if (!connected) continue if (!connected) continue
const key = canon(url) const key = canon(url)
if (!key) continue if (!key) continue
if (shouldKeepProfileRelaySocketOpen(url)) continue
if (hasActiveSubs(key)) continue if (hasActiveSubs(key)) continue
const last = lastActivityMs.get(key) ?? 0 const last = lastActivityMs.get(key) ?? 0
if (now - last < RELAY_POOL_SOCKET_IDLE_MS) continue if (now - last < RELAY_POOL_SOCKET_IDLE_MS) continue
@ -81,6 +88,7 @@ export function closeRelayPoolSocketsIfIdle(urls: readonly string[]): void {
for (const raw of urls) { for (const raw of urls) {
const key = canon(raw) const key = canon(raw)
if (!key || hasActiveSubs(key)) continue if (!key || hasActiveSubs(key)) continue
if (shouldKeepProfileRelaySocketOpen(raw)) continue
const normalized = normalizeAnyRelayUrl(raw) || raw const normalized = normalizeAnyRelayUrl(raw) || raw
const connected = [...status.entries()].some( const connected = [...status.entries()].some(
([u, ok]) => ok && canon(u) === key ([u, ok]) => ok && canon(u) === key
@ -96,6 +104,43 @@ export function closeRelayPoolSocketsIfIdle(urls: readonly string[]): void {
} }
} }
/**
* After publish: drop sockets opened for author outboxes, random NIP-66 picks, and other non-personal
* targets. Keeps profile index relays and the viewer's own list relays connected.
*/
export function closePublishTransientRelaySockets(urls: readonly string[]): void {
if (!pool || !hasActiveSubs || urls.length === 0) return
let status: Map<string, boolean>
try {
status = pool.listConnectionStatus()
} catch {
return
}
const toClose: string[] = []
for (const raw of urls) {
if (isRelayUrlInViewerMetadataLists(raw)) continue
if (isMetadataPolicyProfileRelay(raw)) continue
const key = canon(raw)
if (!key || hasActiveSubs(key)) continue
const normalized = normalizeAnyRelayUrl(raw) || raw
const connected = [...status.entries()].some(
([u, ok]) => ok && canon(u) === key
)
if (connected) toClose.push(normalized)
}
if (toClose.length === 0) return
try {
pool.close(toClose)
logger.debug('[RelayPoolIdle] closed publish transient sockets', { relays: toClose })
} catch {
/* ignore */
}
for (const url of toClose) {
lastActivityMs.delete(canon(url))
}
}
export function resetRelayPoolIdleForTests(): void { export function resetRelayPoolIdleForTests(): void {
if (sweepTimer != null) { if (sweepTimer != null) {
clearInterval(sweepTimer) clearInterval(sweepTimer)

2
src/pages/primary/ExplorePage/index.tsx

@ -3,7 +3,6 @@ import { buildExplorePopularRelayUrls } from '@/lib/explore-popular-relays'
import { RefreshButton } from '@/components/RefreshButton' import { RefreshButton } from '@/components/RefreshButton'
import PrimaryPageLayout from '@/layouts/PrimaryPageLayout' import PrimaryPageLayout from '@/layouts/PrimaryPageLayout'
import { syncUserDeletionTombstones } from '@/lib/sync-user-deletions' import { syncUserDeletionTombstones } from '@/lib/sync-user-deletions'
import { useBypassMetadataRelaysOnlyPolicy } from '@/hooks/useBypassMetadataRelaysOnlyPolicy'
import { useSmartRelayNavigation } from '@/PageManager' import { useSmartRelayNavigation } from '@/PageManager'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import client from '@/services/client.service' import client from '@/services/client.service'
@ -62,7 +61,6 @@ function filterMonitoringRelaySuggestions(urls: string[], rawQuery: string): str
} }
const ExplorePage = forwardRef<TPageRef>((_, ref) => { const ExplorePage = forwardRef<TPageRef>((_, ref) => {
useBypassMetadataRelaysOnlyPolicy()
const { pubkey, relayList } = useNostr() const { pubkey, relayList } = useNostr()
const layoutRef = useRef<TPageRef>(null) const layoutRef = useRef<TPageRef>(null)
const [contentRefreshKey, setContentRefreshKey] = useState(0) const [contentRefreshKey, setContentRefreshKey] = useState(0)

2
src/pages/primary/SearchPage/index.tsx

@ -8,12 +8,10 @@ import { useNostr } from '@/providers/NostrProvider'
import { TPageRef, TSearchParams } from '@/types' import { TPageRef, TSearchParams } from '@/types'
import { BookOpen, Search } from 'lucide-react' import { BookOpen, Search } from 'lucide-react'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { useBypassMetadataRelaysOnlyPolicy } from '@/hooks/useBypassMetadataRelaysOnlyPolicy'
import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react' import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
const SearchPage = forwardRef<TPageRef>((_props, ref) => { const SearchPage = forwardRef<TPageRef>((_props, ref) => {
useBypassMetadataRelaysOnlyPolicy()
const { t } = useTranslation() const { t } = useTranslation()
const { current, display } = usePrimaryPage() const { current, display } = usePrimaryPage()
const { pubkey, relayList } = useNostr() const { pubkey, relayList } = useNostr()

2
src/pages/secondary/RelayReviewsPage/index.tsx

@ -10,13 +10,11 @@ import { normalizeRelayUrlForPage, simplifyUrl } from '@/lib/url'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import type { TFeedSubRequest } from '@/types' import type { TFeedSubRequest } from '@/types'
import { useBypassMetadataRelaysOnlyPolicy } from '@/hooks/useBypassMetadataRelaysOnlyPolicy'
import { forwardRef, useCallback, useEffect, useMemo, useRef } from 'react' import { forwardRef, useCallback, useEffect, useMemo, useRef } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import NotFoundPage from '../NotFoundPage' import NotFoundPage from '../NotFoundPage'
const RelayReviewsPage = forwardRef(({ url, index, hideTitlebar = false }: { url?: string; index?: number; hideTitlebar?: boolean }, ref) => { const RelayReviewsPage = forwardRef(({ url, index, hideTitlebar = false }: { url?: string; index?: number; hideTitlebar?: boolean }, ref) => {
useBypassMetadataRelaysOnlyPolicy()
const { t } = useTranslation() const { t } = useTranslation()
const { registerPrimaryPanelRefresh } = usePrimaryNoteView() const { registerPrimaryPanelRefresh } = usePrimaryNoteView()
const feedRef = useRef<TNoteListRef>(null) const feedRef = useRef<TNoteListRef>(null)

4
src/pages/secondary/RelaySettingsPage/index.tsx

@ -1,5 +1,4 @@
import CacheRelaysSetting from '@/components/CacheRelaysSetting' import CacheRelaysSetting from '@/components/CacheRelaysSetting'
import MetadataRelaysOnlySetting from '@/components/MetadataRelaysOnlySetting'
import HttpRelaysSetting from '@/components/HttpRelaysSetting' import HttpRelaysSetting from '@/components/HttpRelaysSetting'
import JsonViewDialog from '@/components/JsonViewDialog' import JsonViewDialog from '@/components/JsonViewDialog'
import MailboxSetting from '@/components/MailboxSetting' import MailboxSetting from '@/components/MailboxSetting'
@ -121,9 +120,6 @@ const RelaySettingsPage = forwardRef(({ index, hideTitlebar = false }: { index?:
} }
> >
<JsonViewDialog value={jsonPayload} isOpen={jsonOpen} onClose={() => setJsonOpen(false)} /> <JsonViewDialog value={jsonPayload} isOpen={jsonOpen} onClose={() => setJsonOpen(false)} />
<div className="px-4 pt-3">
<MetadataRelaysOnlySetting />
</div>
<Tabs key={contentKey} value={tabValue} onValueChange={setTabValue} className="px-4 py-3 space-y-4"> <Tabs key={contentKey} value={tabValue} onValueChange={setTabValue} className="px-4 py-3 space-y-4">
<TabsList className="flex-col sm:flex-row h-auto sm:h-9"> <TabsList className="flex-col sm:flex-row h-auto sm:h-9">
<TabsTrigger value="favorite-relays" className="w-full sm:w-auto">{t('Favorite Relays')}</TabsTrigger> <TabsTrigger value="favorite-relays" className="w-full sm:w-auto">{t('Favorite Relays')}</TabsTrigger>

2
src/pages/secondary/SearchPage/index.tsx

@ -11,12 +11,10 @@ import { useNostr } from '@/providers/NostrProvider'
import { BookOpen } from 'lucide-react' import { BookOpen } from 'lucide-react'
import { TSearchParams } from '@/types' import { TSearchParams } from '@/types'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { useBypassMetadataRelaysOnlyPolicy } from '@/hooks/useBypassMetadataRelaysOnlyPolicy'
import { forwardRef, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { forwardRef, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
const SearchPage = forwardRef(({ index, hideTitlebar = false }: { index?: number; hideTitlebar?: boolean }, ref) => { const SearchPage = forwardRef(({ index, hideTitlebar = false }: { index?: number; hideTitlebar?: boolean }, ref) => {
useBypassMetadataRelaysOnlyPolicy()
const { t } = useTranslation() const { t } = useTranslation()
const { registerPrimaryPanelRefresh } = usePrimaryNoteView() const { registerPrimaryPanelRefresh } = usePrimaryNoteView()
const { push } = useSecondaryPage() const { push } = useSecondaryPage()

5
src/providers/FeedProvider.test.ts

@ -4,7 +4,6 @@ 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, isWispTrendingNotesRelayUrl } from '@/lib/wisp-trending-relay' import { buildWispTrendingNotesRelayUrl, isWispTrendingNotesRelayUrl } from '@/lib/wisp-trending-relay'
import { import {
setRestrictConnectionsToMetadataRelaysOnly,
setViewerPersonalRelayKeys setViewerPersonalRelayKeys
} from '@/lib/read-only-relay-personal' } from '@/lib/read-only-relay-personal'
@ -43,14 +42,12 @@ 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', () => { it('personal-relay policy omits wisp trending from home feed relay list', () => {
setRestrictConnectionsToMetadataRelaysOnly(true)
setViewerPersonalRelayKeys(new Set(['wss://relay.example.com/']), { viewerActive: true }) setViewerPersonalRelayKeys(new Set(['wss://relay.example.com/']), { viewerActive: true })
const wisp = buildWispTrendingNotesRelayUrl() const wisp = buildWispTrendingNotesRelayUrl()
const urls = buildAllFavoritesFeedRelayUrls(['wss://relay.example.com/'], [], [wisp]) const urls = buildAllFavoritesFeedRelayUrls(['wss://relay.example.com/'], [], [wisp])
expect(urls).toContain('wss://relay.example.com/') expect(urls).toContain('wss://relay.example.com/')
expect(urls.some((u) => isWispTrendingNotesRelayUrl(u))).toBe(false) expect(urls.some((u) => isWispTrendingNotesRelayUrl(u))).toBe(false)
setRestrictConnectionsToMetadataRelaysOnly(false)
setViewerPersonalRelayKeys(new Set(), { viewerActive: false }) setViewerPersonalRelayKeys(new Set(), { viewerActive: false })
}) })

11
src/providers/FeedProvider.tsx

@ -6,12 +6,11 @@ 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 { collectUserWriteOutboxUrls } from '@/lib/viewer-write-outboxes'
import { getCacheRelayUrlsFromEvent } from '@/lib/private-relays' import { getCacheRelayUrlsFromEvent } from '@/lib/private-relays'
import { normalizeAnyRelayUrl } from '@/lib/url' import { normalizeAnyRelayUrl } from '@/lib/url'
import { viewerUsesGlobalRelayDefaults } from '@/lib/viewer-relay-defaults' import { viewerUsesGlobalRelayDefaults } from '@/lib/viewer-relay-defaults'
import { collectUserReadInboxUrls } from '@/lib/viewer-read-inboxes'
import { collectUserWriteOutboxUrls } from '@/lib/viewer-write-outboxes'
import { buildWispTrendingNotesRelayUrl } from '@/lib/wisp-trending-relay' import { buildWispTrendingNotesRelayUrl } from '@/lib/wisp-trending-relay'
import { useEffect, useMemo, useState, useCallback, useRef } from 'react' import { useEffect, useMemo, useState, useCallback, useRef } from 'react'
import type { Dispatch, ReactNode, SetStateAction } from 'react' import type { Dispatch, ReactNode, SetStateAction } from 'react'
@ -265,12 +264,6 @@ 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(

5
src/providers/NostrProvider/index.tsx

@ -1615,14 +1615,16 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
client.interruptBackgroundQueries() client.interruptBackgroundQueries()
noteStatsService.beginPublishPriority() noteStatsService.beginPublishPriority()
let publishRelayCandidates: string[] = []
try { try {
logger.debug('[Publish] Determining target relays...', { kind: event.kind, pubkey: event.pubkey?.substring(0, 8) }) logger.debug('[Publish] Determining target relays...', { kind: event.kind, pubkey: event.pubkey?.substring(0, 8) })
const favoriteRelayUrls = favoriteRelayUrlsForPublish(favoriteRelaysEvent, account.pubkey, relayList) const favoriteRelayUrls = favoriteRelayUrlsForPublish(favoriteRelaysEvent, account.pubkey, relayList)
const relays = await client.determineTargetRelays(event, { publishRelayCandidates = await client.determineTargetRelays(event, {
...options, ...options,
favoriteRelayUrls, favoriteRelayUrls,
blockedRelayUrls: options.blockedRelayUrls ?? blockedRelayUrlsFromEvent(blockedRelaysEvent) blockedRelayUrls: options.blockedRelayUrls ?? blockedRelayUrlsFromEvent(blockedRelaysEvent)
}) })
const relays = publishRelayCandidates
logger.debug('[Publish] Target relays determined', { relayCount: relays.length, relays: relays.slice(0, 5) }) logger.debug('[Publish] Target relays determined', { relayCount: relays.length, relays: relays.slice(0, 5) })
logger.debug('[Publish] Calling client.publishEvent()...', { relayCount: relays.length, eventId: event.id?.substring(0, 8) }) logger.debug('[Publish] Calling client.publishEvent()...', { relayCount: relays.length, eventId: event.id?.substring(0, 8) })
@ -1740,6 +1742,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
throw error throw error
} finally { } finally {
noteStatsService.endPublishPriority() noteStatsService.endPublishPriority()
client.closePublishTransientRelays(publishRelayCandidates)
} }
} }

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

@ -444,10 +444,18 @@ export class QueryService {
onevent?: (evt: NEvent) => void, onevent?: (evt: NEvent) => void,
options?: QueryOptions options?: QueryOptions
): Promise<NEvent[]> { ): Promise<NEvent[]> {
urls = sanitizeRelayUrlsForFetch(urls) const originalUrls = [...urls]
const revokeOperationScope = grantRelayConnectionOperationScope(originalUrls)
urls = sanitizeRelayUrlsForFetch(originalUrls)
const sanitizedFilters = sanitizeFiltersBeforeReq(filter) const sanitizedFilters = sanitizeFiltersBeforeReq(filter)
if (sanitizedFilters.length === 0) return [] if (sanitizedFilters.length === 0) {
if (options?.signal?.aborted) return [] revokeOperationScope()
return []
}
if (options?.signal?.aborted) {
revokeOperationScope()
return []
}
const maxFilters = RELAY_REQ_MAX_FILTERS_PER_MESSAGE const maxFilters = RELAY_REQ_MAX_FILTERS_PER_MESSAGE
if (sanitizedFilters.length > maxFilters) { if (sanitizedFilters.length > maxFilters) {
@ -535,7 +543,6 @@ 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()
@ -845,6 +852,7 @@ export class QueryService {
return { close: () => {} } return { close: () => {} }
} }
const originalDedupedRelays = Array.from(new Set(urls)) const originalDedupedRelays = Array.from(new Set(urls))
const revokeOperationScope = grantRelayConnectionOperationScope(originalDedupedRelays)
let relays = sanitizeRelayUrlsForFetch(originalDedupedRelays) let relays = sanitizeRelayUrlsForFetch(originalDedupedRelays)
const stripSocialBlockedRelays = const stripSocialBlockedRelays =
@ -870,12 +878,11 @@ export class QueryService {
} }
if (relays.length === 0) { if (relays.length === 0) {
revokeOperationScope()
queueMicrotask(() => callbacks.oneose?.(true)) queueMicrotask(() => callbacks.oneose?.(true))
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) {

138
src/services/client.service.ts

@ -47,6 +47,7 @@ import {
collectViewerWriteOutboxUrls, collectViewerWriteOutboxUrls,
collectWriteOutboxUrlsFromRelayList collectWriteOutboxUrlsFromRelayList
} from '@/lib/viewer-write-outboxes' } from '@/lib/viewer-write-outboxes'
import { isMetadataPolicyProfileRelay } from '@/lib/metadata-policy-curated-relays'
import { import {
buildPersonalRelayKeySet, buildPersonalRelayKeySet,
sanitizeRelayUrlsForFetch, sanitizeRelayUrlsForFetch,
@ -54,7 +55,7 @@ import {
isReadOnlyRelayAllowedForViewer, isReadOnlyRelayAllowedForViewer,
isRelayConnectionAllowedForViewer, isRelayConnectionAllowedForViewer,
isMetadataRelaysOnlyPolicyActive, isMetadataRelaysOnlyPolicyActive,
isRestrictConnectionsToMetadataRelaysOnly, isRelayUrlInViewerMetadataLists,
grantRelayConnectionOperationScope, grantRelayConnectionOperationScope,
enterSingleRelayExplicitFetchScope, enterSingleRelayExplicitFetchScope,
isSingleRelayExplicitBrowseActive, isSingleRelayExplicitBrowseActive,
@ -200,7 +201,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, closeRelayPoolSocketsIfIdle } from '@/lib/relay-pool-idle' import { initRelayPoolIdle, touchRelayPoolActivity, closePublishTransientRelaySockets, 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 {
@ -433,6 +434,8 @@ class ClientService extends EventTarget {
/** Session-only: relay URL -> { successCount, sumLatencyMs } for preferring faster, proven relays when picking "random" relays. */ /** Session-only: relay URL -> { successCount, sumLatencyMs } for preferring faster, proven relays when picking "random" relays. */
private sessionRelayPublishStats = new Map<string, { successCount: number; sumLatencyMs: number }>() private sessionRelayPublishStats = new Map<string, { successCount: number; sumLatencyMs: number }>()
/** Author outbox / random publish targets to close after publish (not personal or profile index). */
private publishTransientRelayUrls = new Set<string>()
/** /**
* IndexedDB profile index + NIP-66 relay discovery run once per page session. When logged in, * IndexedDB profile index + NIP-66 relay discovery run once per page session. When logged in,
@ -716,9 +719,7 @@ class ClientService extends EventTarget {
/** IndexedDB-first: personal lists (incl. cache + HTTP) before policy or network so locals stay allowed. */ /** IndexedDB-first: personal lists (incl. cache + HTTP) before policy or network so locals stay allowed. */
const storageUrls = await this.collectViewerPersonalRelayUrlsFromStorage(pk) const storageUrls = await this.collectViewerPersonalRelayUrlsFromStorage(pk)
this.viewerHttpIndexRelayBases = storageUrls.httpIndexBases this.viewerHttpIndexRelayBases = storageUrls.httpIndexBases
setViewerPersonalRelayKeys(buildPersonalRelayKeySet(storageUrls.all), { setViewerPersonalRelayKeys(buildPersonalRelayKeySet(storageUrls.all), { viewerActive: true })
viewerActive: isRestrictConnectionsToMetadataRelaysOnly()
})
syncViewerRelayStackNostrLandAggrEligible(storageUrls.all) syncViewerRelayStackNostrLandAggrEligible(storageUrls.all)
relaySessionStrikes.setSessionCacheRelayKeysFromKind10432(storageUrls.cacheRelayEvent) relaySessionStrikes.setSessionCacheRelayKeysFromKind10432(storageUrls.cacheRelayEvent)
this.closeMetadataPolicyDisallowedRelayConnections() this.closeMetadataPolicyDisallowedRelayConnections()
@ -1201,6 +1202,11 @@ class ClientService extends EventTarget {
event: NEvent, event: NEvent,
{ specifiedRelayUrls, additionalRelayUrls, favoriteRelayUrls, blockedRelayUrls }: TPublishOptions = {} { specifiedRelayUrls, additionalRelayUrls, favoriteRelayUrls, blockedRelayUrls }: TPublishOptions = {}
) { ) {
this.publishTransientRelayUrls.clear()
const finish = (relays: string[]): string[] => {
this.stagePublishTransientRelays(relays)
return relays
}
const writeRelayPubOpts = { const writeRelayPubOpts = {
blockedRelays: blockedRelayUrls, blockedRelays: blockedRelayUrls,
applySocialKindBlockedFilter: isSocialKindBlockedKind(event.kind) applySocialKindBlockedFilter: isSocialKindBlockedKind(event.kind)
@ -1244,9 +1250,23 @@ class ClientService extends EventTarget {
if (userWriteRelays.length === 0 && seenRelays.length === 0) { if (userWriteRelays.length === 0 && seenRelays.length === 0) {
if (!useGlobalRelayDefaults) { if (!useGlobalRelayDefaults) {
return this.filterPublishingRelays( return finish(
this.filterPublishingRelays(
buildPrioritizedWriteRelayUrls({
userWriteRelays: [],
favoriteRelays: favoriteRelayUrls ?? [],
maxRelays: MAX_PUBLISH_RELAYS,
includeGlobalFastWriteReadTails: false,
...writeRelayPubOpts
}),
event
)
)
}
return finish(
this.filterPublishingRelays(
buildPrioritizedWriteRelayUrls({ buildPrioritizedWriteRelayUrls({
userWriteRelays: [], userWriteRelays: [...FAST_WRITE_RELAY_URLS],
favoriteRelays: favoriteRelayUrls ?? [], favoriteRelays: favoriteRelayUrls ?? [],
maxRelays: MAX_PUBLISH_RELAYS, maxRelays: MAX_PUBLISH_RELAYS,
includeGlobalFastWriteReadTails: false, includeGlobalFastWriteReadTails: false,
@ -1254,29 +1274,21 @@ class ClientService extends EventTarget {
}), }),
event event
) )
} )
return this.filterPublishingRelays( }
return finish(
this.filterPublishingRelays(
buildPrioritizedWriteRelayUrls({ buildPrioritizedWriteRelayUrls({
userWriteRelays: [...FAST_WRITE_RELAY_URLS], userWriteRelays: userWriteRelays,
authorReadRelays: [],
favoriteRelays: favoriteRelayUrls ?? [], favoriteRelays: favoriteRelayUrls ?? [],
extraRelays: seenRelays,
maxRelays: MAX_PUBLISH_RELAYS, maxRelays: MAX_PUBLISH_RELAYS,
includeGlobalFastWriteReadTails: false, includeGlobalFastWriteReadTails: useGlobalRelayDefaults,
...writeRelayPubOpts ...writeRelayPubOpts
}), }),
event event
) )
}
return this.filterPublishingRelays(
buildPrioritizedWriteRelayUrls({
userWriteRelays: userWriteRelays,
authorReadRelays: [],
favoriteRelays: favoriteRelayUrls ?? [],
extraRelays: seenRelays,
maxRelays: MAX_PUBLISH_RELAYS,
includeGlobalFastWriteReadTails: useGlobalRelayDefaults,
...writeRelayPubOpts
}),
event
) )
} }
@ -1313,7 +1325,7 @@ class ClientService extends EventTarget {
authorWriteCount: authorWrite.length, authorWriteCount: authorWrite.length,
recipientReadCount: recipientRead.length recipientReadCount: recipientRead.length
}) })
return pubRelays return finish(pubRelays)
} }
// Payment attestations (9741): attester outbox + attester read inboxes (profile wall REQ) + // Payment attestations (9741): attester outbox + attester read inboxes (profile wall REQ) +
@ -1355,7 +1367,7 @@ class ClientService extends EventTarget {
senderInboxCount: senderInboxes.length, senderInboxCount: senderInboxes.length,
seenRelayCount: seenRelays.length seenRelayCount: seenRelays.length
}) })
return attestationRelays return finish(attestationRelays)
} }
let relays: string[] let relays: string[]
@ -1383,22 +1395,24 @@ class ClientService extends EventTarget {
const n = normalizeRelayUrlByScheme(url) || url const n = normalizeRelayUrlByScheme(url) || url
return !readOnlySet.has(n) return !readOnlySet.has(n)
}) })
return this.filterPublishingRelays( return finish(
buildPrioritizedWriteRelayUrls({ this.filterPublishingRelays(
userWriteRelays: buildPrioritizedWriteRelayUrls({
spellWriteFiltered.length > 0 userWriteRelays:
? spellWriteFiltered spellWriteFiltered.length > 0
: useGlobalRelayDefaults ? spellWriteFiltered
? dedupeNormalizeRelayUrlsOrdered(FAST_WRITE_RELAY_URLS) : useGlobalRelayDefaults
: [], ? dedupeNormalizeRelayUrlsOrdered(FAST_WRITE_RELAY_URLS)
favoriteRelays: favoriteRelayUrls ?? [], : [],
extraRelays: [], favoriteRelays: favoriteRelayUrls ?? [],
maxRelays: MAX_PUBLISH_RELAYS, extraRelays: [],
includeGlobalFastWriteReadTails: maxRelays: MAX_PUBLISH_RELAYS,
spellWriteFiltered.length > 0 ? useGlobalRelayDefaults : false, includeGlobalFastWriteReadTails:
...writeRelayPubOpts spellWriteFiltered.length > 0 ? useGlobalRelayDefaults : false,
}), ...writeRelayPubOpts
event }),
event
)
) )
} }
@ -1539,7 +1553,7 @@ class ClientService extends EventTarget {
} else { } else {
relays = dedupeNormalizeRelayUrlsOrdered(relays).slice(0, MAX_PUBLISH_RELAYS) relays = dedupeNormalizeRelayUrlsOrdered(relays).slice(0, MAX_PUBLISH_RELAYS)
} }
return relays return finish(relays)
} }
/** NOTICE handler: session strikes + rate-limit cooldown + debug log for fetch failures. */ /** NOTICE handler: session strikes + rate-limit cooldown + debug log for fetch failures. */
@ -1621,6 +1635,34 @@ class ClientService extends EventTarget {
relaySessionStrikes.clearKey(urlOrSessionKey) relaySessionStrikes.clearKey(urlOrSessionKey)
} }
/** Stage non-personal publish targets (e.g. from {@link determineTargetRelays}) for post-publish socket cleanup. */
stagePublishTransientRelays(urls: readonly string[]): void {
for (const raw of urls) {
if (!raw?.trim()) continue
if (isRelayUrlInViewerMetadataLists(raw)) continue
if (isMetadataPolicyProfileRelay(raw)) continue
this.publishTransientRelayUrls.add(normalizeUrl(raw) || raw.trim())
}
}
/** Close author outbox / random publish sockets; keeps profile index and viewer list relays up. */
closePublishTransientRelays(extraUrls?: readonly string[]): void {
const seen = new Set<string>()
const urls: string[] = []
const add = (list: readonly string[]) => {
for (const raw of list) {
const n = normalizeUrl(raw) || raw.trim()
if (!n || seen.has(n)) continue
seen.add(n)
urls.push(n)
}
}
add([...this.publishTransientRelayUrls])
if (extraUrls?.length) add(extraUrls)
this.publishTransientRelayUrls.clear()
closePublishTransientRelaySockets(urls)
}
/** /**
* From a list of candidate relay URLs (e.g. public lively), return up to `count` relays, * From a list of candidate relay URLs (e.g. public lively), return up to `count` relays,
* preferring those that have succeeded and been fast this session. Excludes read-only relays. * preferring those that have succeeded and been fast this session. Excludes read-only relays.
@ -1788,7 +1830,10 @@ 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)) queueMicrotask(() => {
closePublishTransientRelaySockets(publishTargetUrls)
client.closePublishTransientRelays()
})
} }
/** /**
@ -2635,6 +2680,7 @@ class ClientService extends EventTarget {
originalDedupedRelays.length === 1 && originalDedupedRelays.length === 1 &&
(singleRelayExplicit === true || isSingleRelayExplicitBrowseActive()) (singleRelayExplicit === true || isSingleRelayExplicitBrowseActive())
const revokeFetchScope = preserveExplicitSingleRelay ? enterSingleRelayExplicitFetchScope() : () => {} const revokeFetchScope = preserveExplicitSingleRelay ? enterSingleRelayExplicitFetchScope() : () => {}
const revokeOperationScope = grantRelayConnectionOperationScope(originalDedupedRelays)
const httpKeys = new Set( const httpKeys = new Set(
httpIndexBasesForRelayQuery(originalDedupedRelays, this.viewerHttpIndexRelayBases).map((u) => httpIndexBasesForRelayQuery(originalDedupedRelays, this.viewerHttpIndexRelayBases).map((u) =>
canonicalRelaySessionKey(u) canonicalRelaySessionKey(u)
@ -2661,6 +2707,8 @@ class ClientService extends EventTarget {
oneose?.(true) oneose?.(true)
relayReqLog?.onBatchEnd?.([]) relayReqLog?.onBatchEnd?.([])
}) })
revokeOperationScope()
revokeFetchScope()
return { return {
close: () => {} close: () => {}
} }
@ -2740,13 +2788,13 @@ class ClientService extends EventTarget {
oneose?.(true) oneose?.(true)
relayReqLog?.onBatchEnd?.([]) relayReqLog?.onBatchEnd?.([])
}) })
revokeOperationScope()
revokeFetchScope()
return { return {
close: () => {} close: () => {}
} }
} }
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)}`

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

@ -11,7 +11,6 @@ import { isSameAccount } from '@/lib/account'
import { DEFAULT_ZAP_SATS } from '@/lib/lightning' import { DEFAULT_ZAP_SATS } from '@/lib/lightning'
import { isPaytoCategory } from '@/lib/payto-category-display' import { isPaytoCategory } from '@/lib/payto-category-display'
import type { PaytoCategory } from '@/lib/payto-registry' import type { PaytoCategory } from '@/lib/payto-registry'
import { setRestrictConnectionsToMetadataRelaysOnly } from '@/lib/read-only-relay-personal'
import { randomString } from '@/lib/random' import { randomString } from '@/lib/random'
import { import {
TAccount, TAccount,
@ -80,8 +79,7 @@ const SETTINGS_KEYS = [
StorageKey.DEFAULT_EXPIRATION_ENABLED, StorageKey.DEFAULT_EXPIRATION_ENABLED,
StorageKey.DEFAULT_EXPIRATION_MONTHS, StorageKey.DEFAULT_EXPIRATION_MONTHS,
StorageKey.SHOW_RSS_FEED, StorageKey.SHOW_RSS_FEED,
StorageKey.PANE_MODE, StorageKey.PANE_MODE
StorageKey.RESTRICT_RELAYS_TO_METADATA_LISTS
] as const ] as const
class LocalStorageService { class LocalStorageService {
@ -124,7 +122,6 @@ 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 = true
constructor() { constructor() {
if (!LocalStorageService.instance) { if (!LocalStorageService.instance) {
@ -419,12 +416,6 @@ class LocalStorageService {
const showLiveActivitiesStr = window.localStorage.getItem(StorageKey.SHOW_LIVE_ACTIVITIES_BANNER) const showLiveActivitiesStr = window.localStorage.getItem(StorageKey.SHOW_LIVE_ACTIVITIES_BANNER)
this.showLiveActivitiesBanner = showLiveActivitiesStr !== 'false' this.showLiveActivitiesBanner = showLiveActivitiesStr !== 'false'
const restrictMetadataRelaysStr = window.localStorage.getItem(
StorageKey.RESTRICT_RELAYS_TO_METADATA_LISTS
)
this.restrictRelaysToMetadataLists = restrictMetadataRelaysStr !== 'false'
setRestrictConnectionsToMetadataRelaysOnly(this.restrictRelaysToMetadataLists)
// Clean up deprecated data // Clean up deprecated data
window.localStorage.removeItem(StorageKey.ACCOUNT_PROFILE_EVENT_MAP) window.localStorage.removeItem(StorageKey.ACCOUNT_PROFILE_EVENT_MAP)
window.localStorage.removeItem(StorageKey.ACCOUNT_FOLLOW_LIST_EVENT_MAP) window.localStorage.removeItem(StorageKey.ACCOUNT_FOLLOW_LIST_EVENT_MAP)
@ -433,6 +424,7 @@ class LocalStorageService {
window.localStorage.removeItem(StorageKey.ACCOUNT_MUTE_DECRYPTED_TAGS_MAP) window.localStorage.removeItem(StorageKey.ACCOUNT_MUTE_DECRYPTED_TAGS_MAP)
window.localStorage.removeItem(StorageKey.ACTIVE_RELAY_SET_ID) window.localStorage.removeItem(StorageKey.ACTIVE_RELAY_SET_ID)
window.localStorage.removeItem(StorageKey.FEED_TYPE) window.localStorage.removeItem(StorageKey.FEED_TYPE)
window.localStorage.removeItem(StorageKey.RESTRICT_RELAYS_TO_METADATA_LISTS)
} }
/** Persist a setting. Keys in SETTINGS_KEYS go only to IndexedDB; others use localStorage. */ /** Persist a setting. Keys in SETTINGS_KEYS go only to IndexedDB; others use localStorage. */
@ -615,11 +607,6 @@ class LocalStorageService {
if (showRssStr != null) this.showRssFeed = showRssStr === 'true' if (showRssStr != null) this.showRssFeed = showRssStr === 'true'
const paneStr = get(StorageKey.PANE_MODE) const paneStr = get(StorageKey.PANE_MODE)
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)
if (restrictMetadataRelaysStr != null) {
this.restrictRelaysToMetadataLists = restrictMetadataRelaysStr !== 'false'
setRestrictConnectionsToMetadataRelaysOnly(this.restrictRelaysToMetadataLists)
}
} }
getRelaySets() { getRelaySets() {
@ -1031,16 +1018,6 @@ class LocalStorageService {
this.persistSetting(StorageKey.PANE_MODE, mode) this.persistSetting(StorageKey.PANE_MODE, mode)
} }
getRestrictRelaysToMetadataLists(): boolean {
return this.restrictRelaysToMetadataLists
}
setRestrictRelaysToMetadataLists(restrict: boolean) {
this.restrictRelaysToMetadataLists = restrict
setRestrictConnectionsToMetadataRelaysOnly(restrict)
this.persistSetting(StorageKey.RESTRICT_RELAYS_TO_METADATA_LISTS, restrict.toString())
}
getAccountNetworkHydrateAt(pubkey: string): number | undefined { getAccountNetworkHydrateAt(pubkey: string): number | undefined {
try { try {
const raw = window.localStorage.getItem(StorageKey.ACCOUNT_NETWORK_HYDRATE_AT_MAP) const raw = window.localStorage.getItem(StorageKey.ACCOUNT_NETWORK_HYDRATE_AT_MAP)

Loading…
Cancel
Save