From a3b26f95d33007eb450aa407ad0e599eb4e9e575 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Wed, 3 Jun 2026 12:18:33 +0200 Subject: [PATCH] bug-fixes --- src/components/NoteStats/LikeButton.tsx | 12 ++++--- src/components/NoteStats/RepostButton.tsx | 4 +-- src/components/PostEditor/PostContent.tsx | 2 -- src/hooks/useNip57QuickZap.ts | 29 ++++++++++------- src/i18n/locales/en.ts | 2 ++ src/lib/error-suppression.ts | 7 ++++ src/lib/nostr-relay-auth-patch.ts | 12 +++++++ src/lib/relay-auth-feedback.ts | 25 +++++++++++---- src/lib/relay-nip42-auth.test.ts | 26 +++++++++++++++ src/lib/relay-nip42-auth.ts | 39 ++++++++++++++++++++++- src/providers/NostrProvider/index.tsx | 3 +- src/services/client-query.service.ts | 16 ++++++++-- src/services/client.service.ts | 38 +++++++++++++++++++--- src/services/lightning.service.ts | 4 +-- 14 files changed, 180 insertions(+), 39 deletions(-) create mode 100644 src/lib/relay-nip42-auth.test.ts diff --git a/src/components/NoteStats/LikeButton.tsx b/src/components/NoteStats/LikeButton.tsx index 09db145e..fe13a008 100644 --- a/src/components/NoteStats/LikeButton.tsx +++ b/src/components/NoteStats/LikeButton.tsx @@ -104,7 +104,7 @@ export function LikeButtonWithStats({ const like = async (emoji: string | TEmoji) => { checkLogin(async () => { - if (liking || !pubkey) return + if (liking || !canSignEvents) return setLiking(true) const timer = setTimeout(() => setLiking(false), 10_000) @@ -121,9 +121,11 @@ export function LikeButtonWithStats({ : typeof myLastEmoji === 'object' ? myLastEmoji.shortcode : undefined - const isTogglingOff = showDiscussionVotes - ? discussionVoteMatches(myLastEmoji, emoji) - : myLastEmojiString === emojiString + const isTogglingOff = + pubkey && + (showDiscussionVotes + ? discussionVoteMatches(myLastEmoji, emoji) + : myLastEmojiString === emojiString) logger.debug('Like toggle check', { myLastEmoji, @@ -136,7 +138,7 @@ export function LikeButtonWithStats({ if (isTogglingOff) { // User wants to toggle off - find their previous reaction and delete it const myReaction = noteStats?.likes?.find((like) => { - if (like.pubkey !== pubkey) return false + if (!pubkey || like.pubkey !== pubkey) return false if (showDiscussionVotes) return discussionVoteMatches(like.emoji, emoji) const likeEmojiString = typeof like.emoji === 'string' ? like.emoji : like.emoji.shortcode return likeEmojiString === emojiString diff --git a/src/components/NoteStats/RepostButton.tsx b/src/components/NoteStats/RepostButton.tsx index ce0d79c2..9ceab48a 100644 --- a/src/components/NoteStats/RepostButton.tsx +++ b/src/components/NoteStats/RepostButton.tsx @@ -60,13 +60,13 @@ export function RepostButtonWithStats({ event, hideCount = false, noteStats }: R const repost = async () => { checkLogin(async () => { - if (!canRepost || !pubkey) return + if (!canRepost) return setReposting(true) const timer = setTimeout(() => setReposting(false), 5000) try { - const hasReposted = noteStats?.repostPubkeySet?.has(pubkey) + const hasReposted = pubkey ? noteStats?.repostPubkeySet?.has(pubkey) : false if (hasReposted) return if (!noteStats?.updatedAt) { await noteStatsService.fetchNoteStats(event, pubkey, statsRelays, { foreground: true }) diff --git a/src/components/PostEditor/PostContent.tsx b/src/components/PostEditor/PostContent.tsx index 0a1232de..16127bab 100644 --- a/src/components/PostEditor/PostContent.tsx +++ b/src/components/PostEditor/PostContent.tsx @@ -642,7 +642,6 @@ export default function PostContent({ ? hasNonemptyContent : (mediaNoteKind !== null && mediaUrl) || hasNonemptyContent return ( - !!pubkey && canSignEvents && !posting && !uploadProgresses.length && @@ -658,7 +657,6 @@ export default function PostContent({ relayCapBlockInfo === null ) }, [ - pubkey, canSignEvents, text, getDeterminedKind, diff --git a/src/hooks/useNip57QuickZap.ts b/src/hooks/useNip57QuickZap.ts index a387a389..54a6a998 100644 --- a/src/hooks/useNip57QuickZap.ts +++ b/src/hooks/useNip57QuickZap.ts @@ -20,8 +20,8 @@ export function useNip57QuickZap(opts: { onZapDialogClose?: () => void }) { const { t } = useTranslation() - const { pubkey, account, checkLogin } = useNostr() - const isLoggedIn = Boolean(pubkey && account && account.signerType !== 'npub') + const { pubkey, checkLogin, canSignEvents, isAnonSession } = useNostr() + const isLoggedIn = canSignEvents const { defaultZapSats, defaultZapComment, includePublicZapReceipt } = useZap() const [zapping, setZapping] = useState(false) const ignoreResultRef = useRef(false) @@ -81,7 +81,7 @@ export function useNip57QuickZap(opts: { defaultZapSats >= 1 && nip57Addresses !== null && nip57Addresses.length > 0 && - pubkey !== opts.recipientPubkey + (isAnonSession || pubkey !== opts.recipientPubkey) const recipientNpubLabel = useMemo(() => { const npub = pubkeyToNpub(opts.recipientPubkey) @@ -101,12 +101,12 @@ export function useNip57QuickZap(opts: { const sendQuickZap = useCallback(() => { if (!canQuickNip57Zap || zapping || !nip57Addresses?.length) return checkLogin(async () => { - if (!pubkey) return + if (!canSignEvents) return ignoreResultRef.current = false try { setZapping(true) const zapResult = await lightning.zap( - pubkey, + isAnonSession ? '' : (pubkey ?? ''), opts.referencedEvent ?? opts.recipientPubkey, defaultZapSats, defaultZapComment, @@ -126,13 +126,16 @@ export function useNip57QuickZap(opts: { ) } if (opts.referencedEvent) { - noteStatsService.addZap( - pubkey, - opts.referencedEvent.id, - zapResult.invoice, - defaultZapSats, - defaultZapComment - ) + const zapSenderPubkey = zapResult.zapReceipt?.pubkey ?? pubkey + if (zapSenderPubkey) { + noteStatsService.addZap( + zapSenderPubkey, + opts.referencedEvent.id, + zapResult.invoice, + defaultZapSats, + defaultZapComment + ) + } } } catch (error) { toast.error(`${t('Zap failed')}: ${(error as Error).message}`) @@ -146,6 +149,8 @@ export function useNip57QuickZap(opts: { nip57Addresses, checkLogin, pubkey, + canSignEvents, + isAnonSession, defaultZapSats, defaultZapComment, includePublicZapReceipt, diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index b8e89af1..76a7173c 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -15,6 +15,8 @@ export default { 'The relay accepted authentication (NIP-42): {{relay}}{{detailSuffix}}', 'Relay auth rejected (NIP-42)': 'The relay rejected authentication (NIP-42): {{relay}} — {{message}}', + 'Relay membership required (NIP-42)': + "{{relay}} requires membership or access you don't have — {{message}}", 'Relay auth error unknown': 'Unknown error', Settings: 'Settings', 'Account menu': 'Account menu', diff --git a/src/lib/error-suppression.ts b/src/lib/error-suppression.ts index c80e6caa..bc3c2d00 100644 --- a/src/lib/error-suppression.ts +++ b/src/lib/error-suppression.ts @@ -3,6 +3,8 @@ * This helps reduce noise in the development console */ +import { isRelayAuthAccessDeniedMessage } from '@/lib/relay-nip42-auth' + // Track suppressed errors to avoid spam const suppressedErrors = new Set() @@ -550,6 +552,11 @@ function suppressExpectedRejections() { if (event.reason?.name === 'SendingOnClosedConnection') { event.preventDefault() event.stopPropagation() + return + } + if (event.reason?.name === 'RelayAuthAccessDeniedError' || isRelayAuthAccessDeniedMessage(msg)) { + event.preventDefault() + event.stopPropagation() } }) } diff --git a/src/lib/nostr-relay-auth-patch.ts b/src/lib/nostr-relay-auth-patch.ts index 327bb3be..c4a3f65d 100644 --- a/src/lib/nostr-relay-auth-patch.ts +++ b/src/lib/nostr-relay-auth-patch.ts @@ -1,4 +1,9 @@ import { notifyRelayNip42Accepted, notifyRelayNip42Rejected } from '@/lib/relay-auth-feedback' +import { + NIP42_AUTH_ACCESS_DENIED, + isRelayAuthAccessDeniedMessage +} from '@/lib/relay-nip42-auth' +import { relaySessionStrikes } from '@/lib/relay-strikes' import type { AbstractRelay } from 'nostr-tools/abstract-relay' import type { EventTemplate, VerifiedEvent } from 'nostr-tools' @@ -90,6 +95,13 @@ export function patchPoolRelayAuthRaceAndFeedback(relay: object): void { r.authPromise = undefined return '' } + if (isRelayAuthAccessDeniedMessage(msg)) { + notifyRelayNip42Rejected(url, msg) + r.authPromise = undefined + relaySessionStrikes.recordReadFailure(url, 'connection') + // Resolve (do not reject): pool / nostr-tools may call auth() without `.catch()`. + return NIP42_AUTH_ACCESS_DENIED + } notifyRelayNip42Rejected(url, msg) throw err }) diff --git a/src/lib/relay-auth-feedback.ts b/src/lib/relay-auth-feedback.ts index 356dc275..5c9f21bd 100644 --- a/src/lib/relay-auth-feedback.ts +++ b/src/lib/relay-auth-feedback.ts @@ -1,4 +1,5 @@ import i18n from '@/i18n' +import { isRelayAuthAccessDeniedMessage } from '@/lib/relay-nip42-auth' import { normalizeUrl, simplifyUrl } from '@/lib/url' import logger from '@/lib/logger' import { toast } from 'sonner' @@ -35,12 +36,22 @@ export function notifyRelayNip42Rejected(url: string, message: string): void { const relay = relayLabel(url) const msg = message.trim() || i18n.t('Relay auth error unknown', { defaultValue: 'Unknown error' }) - toast.error( - i18n.t('Relay auth rejected (NIP-42)', { - relay, - message: msg, - defaultValue: `The relay rejected authentication (NIP-42): ${relay} — ${msg}` - }) - ) + if (isRelayAuthAccessDeniedMessage(msg)) { + toast.info( + i18n.t('Relay membership required (NIP-42)', { + relay, + message: msg, + defaultValue: `${relay} requires membership or access you don't have — ${msg}` + }) + ) + } else { + toast.error( + i18n.t('Relay auth rejected (NIP-42)', { + relay, + message: msg, + defaultValue: `The relay rejected authentication (NIP-42): ${relay} — ${msg}` + }) + ) + } logger.warn('[NIP-42] Auth rejected by relay', { url, message: msg }) } diff --git a/src/lib/relay-nip42-auth.test.ts b/src/lib/relay-nip42-auth.test.ts new file mode 100644 index 00000000..230de5b5 --- /dev/null +++ b/src/lib/relay-nip42-auth.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest' +import { isRelayAuthAccessDeniedMessage } from './relay-nip42-auth' + +describe('isRelayAuthAccessDeniedMessage', () => { + it('detects Essayist-style membership restriction', () => { + expect(isRelayAuthAccessDeniedMessage('restricted: active Essayist membership required')).toBe( + true + ) + }) + + it('does not treat auth-required as access denied', () => { + expect(isRelayAuthAccessDeniedMessage('auth-required')).toBe(false) + expect(isRelayAuthAccessDeniedMessage('auth-required: please authenticate')).toBe(false) + }) + + it('detects other permanent denial patterns', () => { + expect(isRelayAuthAccessDeniedMessage('forbidden: not on allowlist')).toBe(true) + expect(isRelayAuthAccessDeniedMessage('membership required')).toBe(true) + expect(isRelayAuthAccessDeniedMessage('access denied')).toBe(true) + }) + + it('ignores empty messages', () => { + expect(isRelayAuthAccessDeniedMessage('')).toBe(false) + expect(isRelayAuthAccessDeniedMessage(' ')).toBe(false) + }) +}) diff --git a/src/lib/relay-nip42-auth.ts b/src/lib/relay-nip42-auth.ts index 10ce04a8..283961a0 100644 --- a/src/lib/relay-nip42-auth.ts +++ b/src/lib/relay-nip42-auth.ts @@ -1,6 +1,36 @@ import type { AbstractRelay } from 'nostr-tools/abstract-relay' import type { EventTemplate, VerifiedEvent } from 'nostr-tools' +/** Resolved (not rejected) by {@link patchPoolRelayAuthRaceAndFeedback} when auth is permanently denied. */ +export const NIP42_AUTH_ACCESS_DENIED = '__jumble_nip42_access_denied__' + +export class RelayAuthAccessDeniedError extends Error { + override readonly name = 'RelayAuthAccessDeniedError' + + constructor(message: string) { + super(message) + } +} + +/** Relay rejected NIP-42 AUTH with a permanent access restriction (membership, allowlist, etc.). */ +export function isRelayAuthAccessDeniedMessage(message: string): boolean { + const trimmed = message.trim() + if (!trimmed) return false + if (isRelayAuthRequiredCloseReason(trimmed) || isRelayAuthRequiredErrorMessage(trimmed)) { + return false + } + const lower = trimmed.toLowerCase() + return ( + lower.startsWith('restricted:') || + lower.startsWith('forbidden:') || + lower.startsWith('blocked:') || + /membership required/i.test(trimmed) || + /access denied/i.test(trimmed) || + /not authorized/i.test(trimmed) || + /not allowed/i.test(trimmed) + ) +} + function readNip42Challenge(relay: AbstractRelay): string | undefined { return (relay as unknown as { challenge?: string }).challenge } @@ -54,5 +84,12 @@ export async function authenticateNip42Relay( "can't perform auth, no challenge was received (timed out waiting for relay AUTH message)" ) } - return relay.auth(signAuthEvent) + const reason = await relay.auth(signAuthEvent) + if (reason === NIP42_AUTH_ACCESS_DENIED) { + throw new RelayAuthAccessDeniedError('relay authentication access denied') + } + if (isRelayAuthAccessDeniedMessage(reason)) { + throw new RelayAuthAccessDeniedError(reason) + } + return reason } diff --git a/src/providers/NostrProvider/index.tsx b/src/providers/NostrProvider/index.tsx index baa9da8c..cca4fc0c 100644 --- a/src/providers/NostrProvider/index.tsx +++ b/src/providers/NostrProvider/index.tsx @@ -7,6 +7,7 @@ import { ACCOUNT_SESSION_NETWORK_HYDRATE_MIN_INTERVAL_MS, DEFAULT_FAVORITE_RELAYS, FAST_READ_RELAY_URLS, + FAST_WRITE_RELAY_URLS, AUTHOR_PROFILE_VIEW_REPLACEABLE_KINDS, ExtendedKind, PROFILE_RELAY_URLS, @@ -113,7 +114,7 @@ function favoriteRelayUrlsForPublish( relayList: TRelayList | null | undefined, account: TAccountPointer | null ): string[] { - if (isAnonAccount(account)) return [...DEFAULT_FAVORITE_RELAYS] + if (isAnonAccount(account)) return [...FAST_WRITE_RELAY_URLS] const urlsFromEvent = (): string[] => { const urls: string[] = [] if (!favoriteRelaysEvent) return urls diff --git a/src/services/client-query.service.ts b/src/services/client-query.service.ts index 0b161e5c..faa1d512 100644 --- a/src/services/client-query.service.ts +++ b/src/services/client-query.service.ts @@ -21,8 +21,10 @@ import { relaySessionStrikes } from '@/lib/relay-strikes' import { queueRelayAuthSign } from '@/lib/relay-auth-sign-queue' import { authenticateNip42Relay, + isRelayAuthAccessDeniedMessage, isRelayAuthRequiredCloseReason, - isRelaySubscriptionClosedByCaller + isRelaySubscriptionClosedByCaller, + RelayAuthAccessDeniedError } from '@/lib/relay-nip42-auth' import { applyRelayNip42AckTimeout } from '@/lib/relay-nip42-tuning' import { isIndexRelayTransportFailure, queryIndexRelay } from '@/lib/index-relay-http' @@ -1091,9 +1093,17 @@ export class QueryService { this.releaseGlobalRelayConnectionSlot() } }) - .catch(() => { + .catch((err) => { nip42ResubscribePending.delete(i) - handleClose(i, reason) + const authMsg = err instanceof Error ? err.message : String(err) + if ( + err instanceof RelayAuthAccessDeniedError || + isRelayAuthAccessDeniedMessage(authMsg) + ) { + nip42HasAuthedOnce.add(i) + relaySessionStrikes.recordReadFailure(url, 'connection') + } + handleClose(i, authMsg || reason) }) return } diff --git a/src/services/client.service.ts b/src/services/client.service.ts index a1a0340d..35e72f33 100644 --- a/src/services/client.service.ts +++ b/src/services/client.service.ts @@ -154,10 +154,12 @@ import { patchPoolRelayAuthRaceAndFeedback } from '@/lib/nostr-relay-auth-patch' import { queueRelayAuthSign } from '@/lib/relay-auth-sign-queue' import { authenticateNip42Relay, + isRelayAuthAccessDeniedMessage, isRelayAuthRequiredCloseReason, isRelayAuthRequiredErrorMessage, isRelayConnectionClosedError, - isRelaySubscriptionClosedByCaller + isRelaySubscriptionClosedByCaller, + RelayAuthAccessDeniedError } from '@/lib/relay-nip42-auth' import { applyRelayNip42AckTimeout } from '@/lib/relay-nip42-tuning' import { buildDeletionRelayUrls, dispatchTombstonesUpdated } from '@/lib/tombstone-events' @@ -211,6 +213,7 @@ import { classifyRelayNotice, relaySessionStrikes } from '@/lib/relay-strikes' import { isSafari } from '@/lib/utils' import { ISigner, + TDraftEvent, TProfile, TPublishEventExtras, TPublishOptions, @@ -908,10 +911,23 @@ class ClientService extends EventTarget { } } + /** Sign with the session signer, or a fresh ephemeral key in anon write mode. */ + async signEventWithSession(draft: TDraftEvent): Promise { + if (this.signerType === 'anon') { + const ephemeral = createEphemeralSigner() + return (await ephemeral.signEvent(draft)) as VerifiedEvent + } + if (!this.signer) { + throw new Error('Please login first to sign the event') + } + return (await this.signer.signEvent(draft)) as VerifiedEvent + } + /** Read-only logins (e.g. npub) cannot sign relay AUTH challenges; avoid calling signEvent. */ private canSignerAuthenticateRelay(): boolean { - if (!this.signer) return false if (this.signerType === 'npub') return false + if (this.signerType === 'anon') return true + if (!this.signer) return false return true } @@ -2102,6 +2118,12 @@ class ClientService extends EventTarget { errors.push({ url, error: authError }) relayStatuses.push({ url, success: false, error: authMsg }) relaySessionStrikes.recordPublishFailure(url, authMsg) + if ( + authError instanceof RelayAuthAccessDeniedError || + isRelayAuthAccessDeniedMessage(authMsg) + ) { + relaySessionStrikes.recordReadFailure(url, 'connection') + } }) } else { logger.error(`[PublishEvent] Publish failed`, { url, error: error.message }) @@ -3063,9 +3085,17 @@ class ClientService extends EventTarget { that.queryService.releaseGlobalRelayConnectionSlot() } }) - .catch(() => { + .catch((err) => { nip42ResubscribePending.delete(i) - handleClose(i, reason) + const authMsg = err instanceof Error ? err.message : String(err) + if ( + err instanceof RelayAuthAccessDeniedError || + isRelayAuthAccessDeniedMessage(authMsg) + ) { + nip42HasAuthedOnce.add(i) + relaySessionStrikes.recordReadFailure(url, 'connection') + } + handleClose(i, authMsg || reason) }) return } diff --git a/src/services/lightning.service.ts b/src/services/lightning.service.ts index 8367bbd3..cd8d9f71 100644 --- a/src/services/lightning.service.ts +++ b/src/services/lightning.service.ts @@ -75,7 +75,7 @@ class LightningService { onPaymentFlowComplete?: (result: PaymentFlowResult) => void, zapLightning?: { address?: string; candidates?: string[] } ): Promise { - if (!client.signer) { + if (!client.signer && client.signerType !== 'anon') { throw new Error('You need to be logged in to zap') } const clampedSats = clampZapSats(sats) @@ -118,7 +118,7 @@ class LightningService { relays, comment }) - const zapRequest = await client.signer.signEvent(zapRequestDraft) + const zapRequest = await client.signEventWithSession(zapRequestDraft) const zapRequestUrl = buildLnurlPayCallbackUrl(callback, { amount: String(amount), nostr: JSON.stringify(zapRequest),