Browse Source

bug-fixes

imwald
Silberengel 1 month ago
parent
commit
fdd93b13cd
  1. 2
      docs/NIP66-MONITOR-SECURITY.md
  2. 2
      src/main.tsx
  3. 17
      src/providers/NostrProvider/index.tsx
  4. 7
      src/services/client-cache.service.ts
  5. 6
      src/services/client-replaceable-events.service.ts
  6. 85
      src/services/client.service.ts
  7. 107
      src/services/nip66-monitor.ts
  8. 26
      src/services/nip66.service.ts
  9. 51
      src/services/relay-info.service.ts

2
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`. 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`. 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__`. 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. 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. 6. **RelayInfo / RelayLivelinessSection** – Use only `window.__RUNTIME_CONFIG__.NIP66_MONITOR_NPUB` (npub) for display.

2
src/main.tsx

@ -9,7 +9,6 @@ import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import App from './App.tsx' import App from './App.tsx'
import { ErrorBoundary } from './components/ErrorBoundary.tsx' import { ErrorBoundary } from './components/ErrorBoundary.tsx'
import { publishMonitorAnnouncementOnce } from './services/nip66-monitor'
import storage from './services/local-storage.service' import storage from './services/local-storage.service'
declare global { declare global {
@ -49,7 +48,6 @@ async function bootstrap() {
} catch { } catch {
// ignore quota or private browsing // ignore quota or private browsing
} }
publishMonitorAnnouncementOnce()
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>
<ErrorBoundary> <ErrorBoundary>

17
src/providers/NostrProvider/index.tsx

@ -106,7 +106,6 @@ export const useNostr = () => {
export function NostrProvider({ children }: { children: React.ReactNode }) { export function NostrProvider({ children }: { children: React.ReactNode }) {
const { t } = useTranslation() const { t } = useTranslation()
// Note: Deletion event handling moved to individual components
const [accounts, setAccounts] = useState<TAccountPointer[]>( const [accounts, setAccounts] = useState<TAccountPointer[]>(
storage.getAccounts().map((act) => ({ pubkey: act.pubkey, signerType: act.signerType })) 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 const mergedRelayList = await client.fetchRelayList(account.pubkey) // Keep using client for relay list merging
setRelayList(mergedRelayList) setRelayList(mergedRelayList)
// Note: Deletion event fetching is now handled locally by individual components const deletionRelayUrls = Array.from(
// for better performance and accuracy 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 = [ const normalizedRelays = [
...relayList.write.map((url: string) => normalizeUrl(url) || url), ...relayList.write.map((url: string) => normalizeUrl(url) || url),
@ -974,8 +982,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
const result = await client.publishEvent(relays, deletionRequest) const result = await client.publishEvent(relays, deletionRequest)
// Note: We don't need to add the deleted event to the provider here await client.applyDeletionRequestToLocalCache(deletionRequest)
// since it's being published as a kind 5 event and will be fetched later
// Show publishing feedback // Show publishing feedback
if (result.relayStatuses) { if (result.relayStatuses) {

7
src/services/client-cache.service.ts

@ -103,7 +103,7 @@ class ClientCacheService {
fetchRelayList: (pubkey: string) => Promise<TRelayList> fetchRelayList: (pubkey: string) => Promise<TRelayList>
fetchFollowList?: (pubkey: string) => Promise<string[]> fetchFollowList?: (pubkey: string) => Promise<string[]>
fetchMuteList?: (pubkey: string) => Promise<NEvent | undefined> fetchMuteList?: (pubkey: string) => Promise<NEvent | undefined>
fetchDeletionEvents?: (relayUrls: string[]) => Promise<void> fetchDeletionEvents?: (relayUrls: string[], authorPubkey?: string) => Promise<void>
}): Promise<void> { }): Promise<void> {
if (this.warmingUp) { if (this.warmingUp) {
logger.debug('[CacheService] Already warming up, skipping') 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) { if (fetchFn.fetchDeletionEvents) {
// This will run in background and update tombstone list const authorPubkey = config.profilePubkeys?.[0]
fetchFn.fetchDeletionEvents([]).catch(err => fetchFn.fetchDeletionEvents([], authorPubkey).catch((err) =>
logger.warn('[CacheService] Failed to fetch deletion events', { error: err }) logger.warn('[CacheService] Failed to fetch deletion events', { error: err })
) )
} }

6
src/services/client-replaceable-events.service.ts

@ -16,7 +16,6 @@ import { TProfile } from '@/types'
import { LRUCache } from 'lru-cache' import { LRUCache } from 'lru-cache'
import indexedDb from './indexed-db.service' import indexedDb from './indexed-db.service'
import type { QueryService } from './client-query.service' import type { QueryService } from './client-query.service'
import { isReplaceableEvent, getReplaceableCoordinateFromEvent } from '@/lib/event'
import logger from '@/lib/logger' import logger from '@/lib/logger'
import client from './client.service' import client from './client.service'
import { buildComprehensiveRelayList } from '@/lib/relay-list-builder' import { buildComprehensiveRelayList } from '@/lib/relay-list-builder'
@ -372,11 +371,6 @@ export class ReplaceableEventService {
if (event && event !== null) { if (event && event !== null) {
results[paramIndex] = event results[paramIndex] = event
eventsMap.set(`${pubkey}:${kind}`, 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 // Refresh in background
this.refreshInBackground(pubkey, kind).catch(() => {}) this.refreshInBackground(pubkey, kind).catch(() => {})
} else { } else {

85
src/services/client.service.ts

@ -1749,54 +1749,69 @@ class ClientService extends EventTarget {
* Returns cached results immediately, then streams relay results via callback. * Returns cached results immediately, then streams relay results via callback.
*/ */
/** /**
* Fetch deletion events (kind 5) and update tombstone list * Record a kind-5 deletion in the local tombstone store (no network).
* This should be called during cache warmup to remove deleted events from cache * Call after publishing a deletion so cache updates without waiting for a fetch.
*/ */
async fetchDeletionEvents(relayUrls: string[] = []): Promise<void> { async applyDeletionRequestToLocalCache(deletionEvent: NEvent): Promise<void> {
// Use all available relays if none specified await this.addTombstoneEntriesFromDeletionEvent(deletionEvent)
const relays = relayUrls.length > 0 const removed = await indexedDb.removeTombstonedFromCache()
? relayUrls if (removed > 0) {
: Array.from(new Set([...PROFILE_FETCH_RELAY_URLS])) logger.info('[ClientService] Removed tombstoned events from cache', { count: removed })
}
}
private async addTombstoneEntriesFromDeletionEvent(deletionEvent: NEvent): Promise<void> {
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<void> {
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 }) logger.info('[ClientService] Fetching deletion events', {
relayCount: relays.length,
authorPubkey: authorPubkey?.slice(0, 12),
})
try { try {
// Fetch latest 100 deletion events const deletionEvents = await this.queryService.query(
const deletionEvents = await this.queryService.query(relays, { relays,
{
kinds: [kinds.EventDeletion], kinds: [kinds.EventDeletion],
limit: 100 limit: 100,
}, undefined, { ...(authorPubkey ? { authors: [authorPubkey] } : {}),
},
undefined,
{
replaceableRace: true, replaceableRace: true,
eoseTimeout: 500, eoseTimeout: 500,
globalTimeout: 5000 globalTimeout: 5000,
}) }
)
logger.debug('[ClientService] Fetched deletion events', { count: deletionEvents.length }) logger.debug('[ClientService] Fetched deletion events', { count: deletionEvents.length })
// Process each deletion event and add to tombstone list
for (const deletionEvent of deletionEvents) { for (const deletionEvent of deletionEvents) {
// Deletion events have 'e' tags for non-replaceable events or 'a' tags for replaceable events await this.addTombstoneEntriesFromDeletionEvent(deletionEvent)
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)
}
}
} }
// Remove tombstoned events from cache
const removed = await indexedDb.removeTombstonedFromCache() const removed = await indexedDb.removeTombstonedFromCache()
if (removed > 0) { if (removed > 0) {
logger.info('[ClientService] Removed tombstoned events from cache', { count: removed }) logger.info('[ClientService] Removed tombstoned events from cache', { count: removed })

107
src/services/nip66-monitor.ts

@ -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-11derived 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 })
})
}

26
src/services/nip66.service.ts

@ -8,7 +8,7 @@
import { normalizeUrl } from '@/lib/url' import { normalizeUrl } from '@/lib/url'
import indexDb from '@/services/indexed-db.service' import indexDb from '@/services/indexed-db.service'
import { TNip66RelayDiscovery, TRelayInfo } from '@/types' import { TNip66RelayDiscovery } from '@/types'
import { Event as NEvent } from 'nostr-tools' import { Event as NEvent } from 'nostr-tools'
const RELAY_DISCOVERY_KIND = 30166 const RELAY_DISCOVERY_KIND = 30166
@ -203,30 +203,6 @@ class Nip66Service {
return this.discoveryByUrl.get(key) 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. */ /** Relay URLs that NIP-66 reports as supporting NIP-50 (search). Do not rely solely on this. */
getSearchableRelayUrls(): string[] { getSearchableRelayUrls(): string[] {
const out: string[] = [] const out: string[] = []

51
src/services/relay-info.service.ts

@ -1,13 +1,9 @@
import { FAST_READ_RELAY_URLS } from '@/constants'
import { simplifyUrl } from '@/lib/url' import { simplifyUrl } from '@/lib/url'
import indexDb from '@/services/indexed-db.service' import indexDb from '@/services/indexed-db.service'
import { TAwesomeRelayCollection, TRelayInfo } from '@/types' import { TAwesomeRelayCollection, TRelayInfo } from '@/types'
import DataLoader from 'dataloader' import DataLoader from 'dataloader'
import FlexSearch from 'flexsearch' import FlexSearch from 'flexsearch'
import logger from '@/lib/logger' 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 { class RelayInfoService {
static instance: RelayInfoService static instance: RelayInfoService
@ -40,9 +36,6 @@ class RelayInfoService {
{ maxBatchSize: 1 } { maxBatchSize: 1 }
) )
private relayUrlsForRandom: string[] = [] private relayUrlsForRandom: string[] = []
/** NIP-66: throttle publishing 30166 per relay (min interval 1 hour). */
private lastNip66PublishByUrl = new Map<string, number>()
private static NIP66_PUBLISH_INTERVAL_MS = 60 * 60 * 1000
/** Relay info cache TTL: refetch NIP-11 after this long (24h). */ /** Relay info cache TTL: refetch NIP-11 after this long (24h). */
private static RELAY_INFO_CACHE_TTL_MS = 24 * 60 * 60 * 1000 private static RELAY_INFO_CACHE_TTL_MS = 24 * 60 * 60 * 1000
@ -147,9 +140,7 @@ class RelayInfoService {
url, url,
shortUrl: simplifyUrl(url) shortUrl: simplifyUrl(url)
} }
const added = await this.addRelayInfo(relayInfo) return await this.addRelayInfo(relayInfo)
this.maybePublishNip66Discovery(added)
return added
} }
private async fetchRelayNip11(url: string) { private async fetchRelayNip11(url: string) {
@ -185,46 +176,6 @@ class RelayInfoService {
]) ])
return relayInfo 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() const instance = RelayInfoService.getInstance()

Loading…
Cancel
Save