Browse Source

bug-fixes

imwald
Silberengel 2 weeks ago
parent
commit
a3b26f95d3
  1. 12
      src/components/NoteStats/LikeButton.tsx
  2. 4
      src/components/NoteStats/RepostButton.tsx
  3. 2
      src/components/PostEditor/PostContent.tsx
  4. 29
      src/hooks/useNip57QuickZap.ts
  5. 2
      src/i18n/locales/en.ts
  6. 7
      src/lib/error-suppression.ts
  7. 12
      src/lib/nostr-relay-auth-patch.ts
  8. 25
      src/lib/relay-auth-feedback.ts
  9. 26
      src/lib/relay-nip42-auth.test.ts
  10. 39
      src/lib/relay-nip42-auth.ts
  11. 3
      src/providers/NostrProvider/index.tsx
  12. 16
      src/services/client-query.service.ts
  13. 38
      src/services/client.service.ts
  14. 4
      src/services/lightning.service.ts

12
src/components/NoteStats/LikeButton.tsx

@ -104,7 +104,7 @@ export function LikeButtonWithStats({ @@ -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({ @@ -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({ @@ -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

4
src/components/NoteStats/RepostButton.tsx

@ -60,13 +60,13 @@ export function RepostButtonWithStats({ event, hideCount = false, noteStats }: R @@ -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 })

2
src/components/PostEditor/PostContent.tsx

@ -642,7 +642,6 @@ export default function PostContent({ @@ -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({ @@ -658,7 +657,6 @@ export default function PostContent({
relayCapBlockInfo === null
)
}, [
pubkey,
canSignEvents,
text,
getDeterminedKind,

29
src/hooks/useNip57QuickZap.ts

@ -20,8 +20,8 @@ export function useNip57QuickZap(opts: { @@ -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: { @@ -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: { @@ -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: { @@ -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: { @@ -146,6 +149,8 @@ export function useNip57QuickZap(opts: {
nip57Addresses,
checkLogin,
pubkey,
canSignEvents,
isAnonSession,
defaultZapSats,
defaultZapComment,
includePublicZapReceipt,

2
src/i18n/locales/en.ts

@ -15,6 +15,8 @@ export default { @@ -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',

7
src/lib/error-suppression.ts

@ -3,6 +3,8 @@ @@ -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<string>()
@ -550,6 +552,11 @@ function suppressExpectedRejections() { @@ -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()
}
})
}

12
src/lib/nostr-relay-auth-patch.ts

@ -1,4 +1,9 @@ @@ -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 { @@ -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
})

25
src/lib/relay-auth-feedback.ts

@ -1,4 +1,5 @@ @@ -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 { @@ -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 })
}

26
src/lib/relay-nip42-auth.test.ts

@ -0,0 +1,26 @@ @@ -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)
})
})

39
src/lib/relay-nip42-auth.ts

@ -1,6 +1,36 @@ @@ -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( @@ -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
}

3
src/providers/NostrProvider/index.tsx

@ -7,6 +7,7 @@ import { @@ -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( @@ -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

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

@ -21,8 +21,10 @@ import { relaySessionStrikes } from '@/lib/relay-strikes' @@ -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 { @@ -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
}

38
src/services/client.service.ts

@ -154,10 +154,12 @@ import { patchPoolRelayAuthRaceAndFeedback } from '@/lib/nostr-relay-auth-patch' @@ -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' @@ -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 { @@ -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<VerifiedEvent> {
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 { @@ -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 { @@ -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
}

4
src/services/lightning.service.ts

@ -75,7 +75,7 @@ class LightningService { @@ -75,7 +75,7 @@ class LightningService {
onPaymentFlowComplete?: (result: PaymentFlowResult) => void,
zapLightning?: { address?: string; candidates?: string[] }
): Promise<PaymentFlowResult> {
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 { @@ -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),

Loading…
Cancel
Save