From fdd93b13cdda309f6dc8dfdea82194f87b8c976a Mon Sep 17 00:00:00 2001 From: Silberengel Date: Sat, 21 Mar 2026 16:48:19 +0100 Subject: [PATCH] bug-fixes --- docs/NIP66-MONITOR-SECURITY.md | 2 +- src/main.tsx | 2 - src/providers/NostrProvider/index.tsx | 19 +++- src/services/client-cache.service.ts | 7 +- .../client-replaceable-events.service.ts | 6 - src/services/client.service.ts | 101 ++++++++++------- src/services/nip66-monitor.ts | 107 ------------------ src/services/nip66.service.ts | 26 +---- src/services/relay-info.service.ts | 51 +-------- 9 files changed, 77 insertions(+), 244 deletions(-) delete mode 100644 src/services/nip66-monitor.ts diff --git a/docs/NIP66-MONITOR-SECURITY.md b/docs/NIP66-MONITOR-SECURITY.md index 4b115801..4763bd16 100644 --- a/docs/NIP66-MONITOR-SECURITY.md +++ b/docs/NIP66-MONITOR-SECURITY.md @@ -20,7 +20,7 @@ The monitor **nsec** (`NIP66_MONITOR_NSEC`) is used only in the **nip66-cron** c 1. **docker-entrypoint.sh** – Writes config.json only from `NIP66_MONITOR_NPUB`; does not read or write `NIP66_MONITOR_NSEC`. 2. **docker-compose.prod.yml** – `NIP66_MONITOR_NSEC` is set only on the **jumble-nip66-monitor** service; **jumble** has only `NIP66_MONITOR_NPUB`. 3. **main.tsx** – Fetches config and types only `NIP66_MONITOR_NPUB`; no nsec in `Window.__RUNTIME_CONFIG__`. -4. **nip66-monitor.ts** (frontend) – Stub only; `getMonitorSecretKey()` always returns `null`; no env or config read for nsec. +4. **Frontend** – No monitor signing code; 30166/10166 publishing is server-only (nip66-cron / jumble-nip66-monitor). 5. **nip66-cron/index.mjs** – Reads nsec from `process.env.NIP66_MONITOR_NSEC` only; never logs it or passes it to `log()`; comment added to never log or expose it. 6. **RelayInfo / RelayLivelinessSection** – Use only `window.__RUNTIME_CONFIG__.NIP66_MONITOR_NPUB` (npub) for display. diff --git a/src/main.tsx b/src/main.tsx index 8ea75212..7f68b316 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -9,7 +9,6 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import App from './App.tsx' import { ErrorBoundary } from './components/ErrorBoundary.tsx' -import { publishMonitorAnnouncementOnce } from './services/nip66-monitor' import storage from './services/local-storage.service' declare global { @@ -49,7 +48,6 @@ async function bootstrap() { } catch { // ignore quota or private browsing } - publishMonitorAnnouncementOnce() createRoot(document.getElementById('root')!).render( diff --git a/src/providers/NostrProvider/index.tsx b/src/providers/NostrProvider/index.tsx index 7614ac5e..4b2508f2 100644 --- a/src/providers/NostrProvider/index.tsx +++ b/src/providers/NostrProvider/index.tsx @@ -106,7 +106,6 @@ export const useNostr = () => { export function NostrProvider({ children }: { children: React.ReactNode }) { const { t } = useTranslation() - // Note: Deletion event handling moved to individual components const [accounts, setAccounts] = useState( storage.getAccounts().map((act) => ({ pubkey: act.pubkey, signerType: act.signerType })) ) @@ -400,8 +399,17 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { const mergedRelayList = await client.fetchRelayList(account.pubkey) // Keep using client for relay list merging setRelayList(mergedRelayList) - // Note: Deletion event fetching is now handled locally by individual components - // for better performance and accuracy + const deletionRelayUrls = Array.from( + new Set([ + ...mergedRelayList.write.map((url: string) => normalizeUrl(url) || url), + ...mergedRelayList.read.slice(0, 8).map((url: string) => normalizeUrl(url) || url), + ...PROFILE_FETCH_RELAY_URLS.map((url: string) => normalizeUrl(url) || url), + ]) + ).slice(0, 20) + + client.fetchDeletionEvents(deletionRelayUrls, account.pubkey).catch((err) => + logger.warn('[NostrProvider] Failed to sync deletion events / tombstones', { error: err }) + ) const normalizedRelays = [ ...relayList.write.map((url: string) => normalizeUrl(url) || url), @@ -974,9 +982,8 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { const result = await client.publishEvent(relays, deletionRequest) - // Note: We don't need to add the deleted event to the provider here - // since it's being published as a kind 5 event and will be fetched later - + await client.applyDeletionRequestToLocalCache(deletionRequest) + // Show publishing feedback if (result.relayStatuses) { showPublishingFeedback(result, { diff --git a/src/services/client-cache.service.ts b/src/services/client-cache.service.ts index 2f126150..1b80a510 100644 --- a/src/services/client-cache.service.ts +++ b/src/services/client-cache.service.ts @@ -103,7 +103,7 @@ class ClientCacheService { fetchRelayList: (pubkey: string) => Promise fetchFollowList?: (pubkey: string) => Promise fetchMuteList?: (pubkey: string) => Promise - fetchDeletionEvents?: (relayUrls: string[]) => Promise + fetchDeletionEvents?: (relayUrls: string[], authorPubkey?: string) => Promise }): Promise { if (this.warmingUp) { logger.debug('[CacheService] Already warming up, skipping') @@ -162,10 +162,9 @@ class ClientCacheService { } } - // Fetch deletion events in background to update tombstone list if (fetchFn.fetchDeletionEvents) { - // This will run in background and update tombstone list - fetchFn.fetchDeletionEvents([]).catch(err => + const authorPubkey = config.profilePubkeys?.[0] + fetchFn.fetchDeletionEvents([], authorPubkey).catch((err) => logger.warn('[CacheService] Failed to fetch deletion events', { error: err }) ) } diff --git a/src/services/client-replaceable-events.service.ts b/src/services/client-replaceable-events.service.ts index 22b48c5e..df8a0e66 100644 --- a/src/services/client-replaceable-events.service.ts +++ b/src/services/client-replaceable-events.service.ts @@ -16,7 +16,6 @@ import { TProfile } from '@/types' import { LRUCache } from 'lru-cache' import indexedDb from './indexed-db.service' import type { QueryService } from './client-query.service' -import { isReplaceableEvent, getReplaceableCoordinateFromEvent } from '@/lib/event' import logger from '@/lib/logger' import client from './client.service' import { buildComprehensiveRelayList } from '@/lib/relay-list-builder' @@ -372,11 +371,6 @@ export class ReplaceableEventService { if (event && event !== null) { results[paramIndex] = event eventsMap.set(`${pubkey}:${kind}`, event) - // Check tombstone in background (non-blocking) - const tombstoneKey = isReplaceableEvent(kind) - ? getReplaceableCoordinateFromEvent(event) - : event.id - indexedDb.isTombstoned(tombstoneKey).catch(() => {}) // Refresh in background this.refreshInBackground(pubkey, kind).catch(() => {}) } else { diff --git a/src/services/client.service.ts b/src/services/client.service.ts index 56e9be9a..e710ca5f 100644 --- a/src/services/client.service.ts +++ b/src/services/client.service.ts @@ -1749,54 +1749,69 @@ class ClientService extends EventTarget { * Returns cached results immediately, then streams relay results via callback. */ /** - * Fetch deletion events (kind 5) and update tombstone list - * This should be called during cache warmup to remove deleted events from cache + * Record a kind-5 deletion in the local tombstone store (no network). + * Call after publishing a deletion so cache updates without waiting for a fetch. */ - async fetchDeletionEvents(relayUrls: string[] = []): Promise { - // Use all available relays if none specified - const relays = relayUrls.length > 0 - ? relayUrls - : Array.from(new Set([...PROFILE_FETCH_RELAY_URLS])) - - logger.info('[ClientService] Fetching deletion events', { profileFetchRelays: PROFILE_FETCH_RELAY_URLS, relayCount: relays.length }) - + async applyDeletionRequestToLocalCache(deletionEvent: NEvent): Promise { + await this.addTombstoneEntriesFromDeletionEvent(deletionEvent) + const removed = await indexedDb.removeTombstonedFromCache() + if (removed > 0) { + logger.info('[ClientService] Removed tombstoned events from cache', { count: removed }) + } + } + + private async addTombstoneEntriesFromDeletionEvent(deletionEvent: NEvent): Promise { + const eTag = deletionEvent.tags.find((tag) => tag[0] === 'e') + const aTag = deletionEvent.tags.find((tag) => tag[0] === 'a') + const kTag = deletionEvent.tags.find((tag) => tag[0] === 'k') + + if (eTag?.[1]) { + await indexedDb.addTombstone(eTag[1]) + } else if (aTag?.[1]) { + await indexedDb.addTombstone(aTag[1]) + } else if (kTag?.[1] && deletionEvent.pubkey) { + const kind = parseInt(kTag[1], 10) + if (!isNaN(kind)) { + await indexedDb.addTombstone(`${kind}:${deletionEvent.pubkey}`) + } + } + } + + /** + * Fetch deletion events (kind 5) and update the tombstone list. + * When `authorPubkey` is set, only that author's deletion requests are queried (typical on login). + */ + async fetchDeletionEvents(relayUrls: string[] = [], authorPubkey?: string): Promise { + const relays = + relayUrls.length > 0 ? relayUrls : Array.from(new Set([...PROFILE_FETCH_RELAY_URLS])) + + logger.info('[ClientService] Fetching deletion events', { + relayCount: relays.length, + authorPubkey: authorPubkey?.slice(0, 12), + }) + try { - // Fetch latest 100 deletion events - const deletionEvents = await this.queryService.query(relays, { - kinds: [kinds.EventDeletion], - limit: 100 - }, undefined, { - replaceableRace: true, - eoseTimeout: 500, - globalTimeout: 5000 - }) - + const deletionEvents = await this.queryService.query( + relays, + { + kinds: [kinds.EventDeletion], + limit: 100, + ...(authorPubkey ? { authors: [authorPubkey] } : {}), + }, + undefined, + { + replaceableRace: true, + eoseTimeout: 500, + globalTimeout: 5000, + } + ) + logger.debug('[ClientService] Fetched deletion events', { count: deletionEvents.length }) - - // Process each deletion event and add to tombstone list + for (const deletionEvent of deletionEvents) { - // Deletion events have 'e' tags for non-replaceable events or 'a' tags for replaceable events - const eTag = deletionEvent.tags.find(tag => tag[0] === 'e') - const aTag = deletionEvent.tags.find(tag => tag[0] === 'a') - const kTag = deletionEvent.tags.find(tag => tag[0] === 'k') - - if (eTag && eTag[1]) { - // Non-replaceable event - use event ID - await indexedDb.addTombstone(eTag[1]) - } else if (aTag && aTag[1]) { - // Replaceable event - a tag format is "kind:pubkey:d" which is already the coordinate - await indexedDb.addTombstone(aTag[1]) - } else if (kTag && kTag[1] && deletionEvent.pubkey) { - // Fallback: if we have kind and pubkey, construct coordinate - const kind = parseInt(kTag[1], 10) - if (!isNaN(kind)) { - const coordinate = `${kind}:${deletionEvent.pubkey}` - await indexedDb.addTombstone(coordinate) - } - } + await this.addTombstoneEntriesFromDeletionEvent(deletionEvent) } - - // Remove tombstoned events from cache + const removed = await indexedDb.removeTombstonedFromCache() if (removed > 0) { logger.info('[ClientService] Removed tombstoned events from cache', { count: removed }) diff --git a/src/services/nip66-monitor.ts b/src/services/nip66-monitor.ts deleted file mode 100644 index 860aa256..00000000 --- a/src/services/nip66-monitor.ts +++ /dev/null @@ -1,107 +0,0 @@ -/** - * NIP-66 relay monitor (client stub). - * Publishing 30166/10166 runs in the server cron only; this module only exposes isNip66MonitorEnabled() === false - * and no-op builders so relay-info and bootstrap can keep calling without branching. - */ - -import { FAST_READ_RELAY_URLS } from '@/constants' -import { normalizeUrl } from '@/lib/url' -import { TRelayInfo } from '@/types' -import { Event as NEvent, finalizeEvent } from 'nostr-tools' -import { ExtendedKind } from '@/constants' -import logger from '@/lib/logger' -import client from '@/services/client.service' - -const RELAY_DISCOVERY_KIND = ExtendedKind.RELAY_DISCOVERY -const RELAY_MONITOR_ANNOUNCEMENT_KIND = ExtendedKind.RELAY_MONITOR_ANNOUNCEMENT - -let publishedAnnouncementThisSession = false - -function getMonitorSecretKey(): Uint8Array | null { - return null -} - -/** False in the client; publishing is done by the server cron. */ -export function isNip66MonitorEnabled(): boolean { - return getMonitorSecretKey() !== null -} - -/** - * Build and sign a kind 30166 relay discovery event from NIP-11–derived relay info. - * Returns null in the client (signing runs in the server cron). - */ -export function buildAndSignDiscoveryEvent(relayInfo: TRelayInfo): NEvent | null { - const sk = getMonitorSecretKey() - if (!sk) return null - - const d = normalizeUrl(relayInfo.url) || relayInfo.url - const tags: string[][] = [['d', d]] - - if (Array.isArray(relayInfo.supported_nips)) { - for (const n of relayInfo.supported_nips) { - tags.push(['N', String(n)]) - } - } - - const lim = relayInfo.limitation - if (lim?.auth_required) tags.push(['R', 'auth']) - else tags.push(['R', '!auth']) - if (lim?.payment_required) tags.push(['R', 'payment']) - else tags.push(['R', '!payment']) - - const draft = { - kind: RELAY_DISCOVERY_KIND, - created_at: Math.floor(Date.now() / 1000), - content: '', - tags - } - - try { - const event = finalizeEvent(draft, sk) - return event as NEvent - } catch (err) { - logger.warn('NIP-66 monitor: failed to sign 30166 event', { err, url: relayInfo.url }) - return null - } -} - -/** - * Build and sign a kind 10166 relay monitor announcement. - * Returns null in the client (handled by server cron). - */ -function buildAndSignMonitorAnnouncement(): NEvent | null { - const sk = getMonitorSecretKey() - if (!sk) return null - const draft = { - kind: RELAY_MONITOR_ANNOUNCEMENT_KIND, - created_at: Math.floor(Date.now() / 1000), - content: '', - tags: [ - ['frequency', '3600'], - ['c', 'nip11'], - ['c', 'ws'] - ] - } - try { - return finalizeEvent(draft, sk) as NEvent - } catch (err) { - logger.warn('NIP-66 monitor: failed to sign 10166 event', { err }) - return null - } -} - -/** No-op in the client; 10166 is published by the server cron on startup. */ -export function publishMonitorAnnouncementOnce(): void { - if (publishedAnnouncementThisSession || !isNip66MonitorEnabled()) return - const event = buildAndSignMonitorAnnouncement() - if (!event) return - publishedAnnouncementThisSession = true - logger.info('NIP-66: publishing monitor announcement (10166)') - client.publishEvent([...FAST_READ_RELAY_URLS.slice(0, 4)], event).then((res) => { - if (res.successCount > 0) { - logger.info('NIP-66: published monitor announcement (10166)', { successCount: res.successCount }) - } - }).catch((err) => { - logger.warn('NIP-66: publish monitor announcement failed', { err }) - }) -} diff --git a/src/services/nip66.service.ts b/src/services/nip66.service.ts index 65f9f10f..c49659dd 100644 --- a/src/services/nip66.service.ts +++ b/src/services/nip66.service.ts @@ -8,7 +8,7 @@ import { normalizeUrl } from '@/lib/url' import indexDb from '@/services/indexed-db.service' -import { TNip66RelayDiscovery, TRelayInfo } from '@/types' +import { TNip66RelayDiscovery } from '@/types' import { Event as NEvent } from 'nostr-tools' const RELAY_DISCOVERY_KIND = 30166 @@ -203,30 +203,6 @@ class Nip66Service { return this.discoveryByUrl.get(key) } - /** - * Ingest relay info from our own monitor (after we publish 30166). Adds the relay to - * in-memory discovery and updates the IndexedDB public lively cache so it can be used - * for random publish relay selection and relay info page liveliness display. - */ - addDiscoveryFromRelayInfo(relayInfo: TRelayInfo): void { - const lim = relayInfo.limitation - const discovery: TNip66RelayDiscovery = { - url: relayInfo.url, - supportedNips: relayInfo.supported_nips ?? [], - requirements: { - auth: lim?.auth_required ?? false, - payment: lim?.payment_required ?? false - }, - created_at: Math.floor(Date.now() / 1000) - } - const key = normalizeUrl(relayInfo.url) || relayInfo.url - this.discoveryByUrl.set(key, discovery) - const publicLively = this.buildPublicLivelyFromDiscovery() - if (publicLively.length > 0 && typeof window !== 'undefined') { - indexDb.setPublicLivelyRelayUrlsCache(publicLively).catch(() => {}) - } - } - /** Relay URLs that NIP-66 reports as supporting NIP-50 (search). Do not rely solely on this. */ getSearchableRelayUrls(): string[] { const out: string[] = [] diff --git a/src/services/relay-info.service.ts b/src/services/relay-info.service.ts index fb3c666d..209d4c46 100644 --- a/src/services/relay-info.service.ts +++ b/src/services/relay-info.service.ts @@ -1,13 +1,9 @@ -import { FAST_READ_RELAY_URLS } from '@/constants' import { simplifyUrl } from '@/lib/url' import indexDb from '@/services/indexed-db.service' import { TAwesomeRelayCollection, TRelayInfo } from '@/types' import DataLoader from 'dataloader' import FlexSearch from 'flexsearch' import logger from '@/lib/logger' -import client from '@/services/client.service' -import { nip66Service } from '@/services/nip66.service' -import { buildAndSignDiscoveryEvent, isNip66MonitorEnabled } from '@/services/nip66-monitor' class RelayInfoService { static instance: RelayInfoService @@ -40,9 +36,6 @@ class RelayInfoService { { maxBatchSize: 1 } ) private relayUrlsForRandom: string[] = [] - /** NIP-66: throttle publishing 30166 per relay (min interval 1 hour). */ - private lastNip66PublishByUrl = new Map() - private static NIP66_PUBLISH_INTERVAL_MS = 60 * 60 * 1000 /** Relay info cache TTL: refetch NIP-11 after this long (24h). */ private static RELAY_INFO_CACHE_TTL_MS = 24 * 60 * 60 * 1000 @@ -147,9 +140,7 @@ class RelayInfoService { url, shortUrl: simplifyUrl(url) } - const added = await this.addRelayInfo(relayInfo) - this.maybePublishNip66Discovery(added) - return added + return await this.addRelayInfo(relayInfo) } private async fetchRelayNip11(url: string) { @@ -185,46 +176,6 @@ class RelayInfoService { ]) return relayInfo } - - /** - * When monitor nsec is set: publish a kind 30166 for this relay after we've fetched NIP-11 - * (only when the fetch was from the network, not from cache). Throttled to once per hour per relay. - * Triggered whenever getRelayInfo/getRelayInfos causes a fresh NIP-11 fetch (e.g. first time - * opening a relay, or relay not in IndexedDB). - */ - private maybePublishNip66Discovery(relayInfo: TRelayInfo): void { - if (!isNip66MonitorEnabled()) { - logger.debug('NIP-66: skip 30166 (publishing is handled by server cron)', { url: relayInfo.url }) - return - } - const key = relayInfo.url - const now = Date.now() - const last = this.lastNip66PublishByUrl.get(key) ?? 0 - if (now - last < RelayInfoService.NIP66_PUBLISH_INTERVAL_MS) { - logger.debug('NIP-66: skip 30166 (throttled, 1h per relay)', { url: relayInfo.url, nextInMin: Math.ceil((RelayInfoService.NIP66_PUBLISH_INTERVAL_MS - (now - last)) / 60000) }) - return - } - - const event = buildAndSignDiscoveryEvent(relayInfo) - if (!event) { - logger.debug('NIP-66: skip 30166 (build/sign failed)', { url: relayInfo.url }) - return - } - - this.lastNip66PublishByUrl.set(key, now) - const urls = [relayInfo.url, ...FAST_READ_RELAY_URLS.slice(0, 3)] - logger.info('NIP-66: publishing relay discovery (30166)', { url: relayInfo.url }) - client.publishEvent(urls, event).then((res) => { - if (res.successCount > 0) { - nip66Service.addDiscoveryFromRelayInfo(relayInfo) - logger.info('NIP-66: published relay discovery (30166)', { url: relayInfo.url, successCount: res.successCount }) - } else { - logger.info('NIP-66: relay discovery (30166) not accepted by any relay', { url: relayInfo.url }) - } - }).catch((err) => { - logger.warn('NIP-66: publish relay discovery failed', { url: relayInfo.url, err }) - }) - } } const instance = RelayInfoService.getInstance()