+
{storedAccounts.map((act) => {
const pk = normalizeHexPubkey(act.pubkey)
const isActive =
+ !isAnonSession &&
+ sessionPubkey != null &&
hexPubkeysEqual(pk, sessionPubkey) &&
(account?.signerType === act.signerType ||
(account?.signerType === 'npub' &&
diff --git a/src/hooks/useSignGatedControl.ts b/src/hooks/useSignGatedControl.ts
index 2ced9ad0..5df8fddf 100644
--- a/src/hooks/useSignGatedControl.ts
+++ b/src/hooks/useSignGatedControl.ts
@@ -2,19 +2,28 @@ import { useNostr } from '@/providers/NostrProvider'
import { useTranslation } from 'react-i18next'
/**
- * Read-only (npub) session helpers for disabling publish / social controls.
+ * Read-only (npub) and anon session helpers for disabling publish / social controls.
*/
export function useSignGatedControl() {
- const { canSignEvents } = useNostr()
+ const { canSignEvents, canManageIdentity, isAnonSession } = useNostr()
const { t } = useTranslation()
const readOnlyTitle = t('readOnlySession.hint')
+ const anonIdentityTitle = t('accountSwitch.anonIdentityDisabled')
return {
canSignEvents,
+ /** Follow, mute, bookmarks, profile lists — requires a stable logged-in identity. */
+ canManageIdentity,
+ isAnonSession,
/** Merge into button/menu props: disabled + title when read-only. */
signControlProps: (extra?: { disabled?: boolean; title?: string }) => ({
disabled: !canSignEvents || Boolean(extra?.disabled),
title: !canSignEvents ? readOnlyTitle : extra?.title
+ }),
+ /** Merge into follow/mute/profile controls. */
+ identityControlProps: (extra?: { disabled?: boolean; title?: string }) => ({
+ disabled: !canManageIdentity || Boolean(extra?.disabled),
+ title: isAnonSession ? anonIdentityTitle : !canManageIdentity ? readOnlyTitle : extra?.title
})
}
}
diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts
index b3420880..b8e89af1 100644
--- a/src/i18n/locales/en.ts
+++ b/src/i18n/locales/en.ts
@@ -1025,6 +1025,15 @@ export default {
'Could not sign in as this account — your extension is using a different key. Switch the key in the extension or tap “Retry extension” below.',
'accountSwitch.extensionUnavailable':
'Could not reach the browser extension. Unlock nos2x/Alby, allow this site, then click the account again.',
+ 'accountSwitch.anon': 'Anon',
+ 'accountSwitch.selectAnon': 'Post anonymously',
+ 'accountSwitch.anonHint':
+ 'Each post, reply, reaction, or auth uses a fresh key. Default read/write relays only.',
+ 'accountSwitch.anonHintShort': 'Fresh key every action · default relays',
+ 'accountSwitch.anonCannotDelete':
+ 'Anonymous sessions cannot delete notes (each action uses a new key).',
+ 'accountSwitch.anonIdentityDisabled':
+ 'Switch to a logged-in account for profile, follow, mute, and other identity features.',
'Show untrusted {type}': 'Show untrusted {{type}}',
'Hide untrusted {type}': 'Hide untrusted {{type}}',
'Currently hiding {type} from untrusted users.':
diff --git a/src/lib/account.ts b/src/lib/account.ts
index 13d4311b..6ef40174 100644
--- a/src/lib/account.ts
+++ b/src/lib/account.ts
@@ -1,6 +1,21 @@
+import { isAnonAccount } from '@/lib/anon-session'
import { accountPubkeyToHex, hexPubkeysEqual } from '@/lib/pubkey'
import { TAccount, TAccountPointer, TSignerType } from '@/types'
+export { createAnonAccountPointer, isAnonAccount } from '@/lib/anon-session'
+
+/** True when the session can sign events (includes anonymous write mode). */
+export function canAccountSignEvents(account: TAccountPointer | null | undefined): boolean {
+ if (!account) return false
+ if (account.signerType === 'npub') return false
+ return true
+}
+
+/** True when the session has a stable identity (follow/mute/profile/lists). False for anon write mode. */
+export function canManageIdentityFeatures(account: TAccountPointer | null | undefined): boolean {
+ return canAccountSignEvents(account) && !isAnonAccount(account)
+}
+
export function isSameAccount(a: TAccountPointer | null, b: TAccountPointer | null) {
if (!a || !b) return false
if (a.signerType !== b.signerType) return false
@@ -41,7 +56,8 @@ const SWITCH_SIGNER_PRIORITY: Record
= {
'browser-nsec': 1,
ncryptsec: 2,
bunker: 3,
- npub: 4
+ npub: 4,
+ anon: 99
}
function normalizedPubkeyHex(account: TAccountPointer): string | null {
diff --git a/src/lib/anon-session.ts b/src/lib/anon-session.ts
new file mode 100644
index 00000000..c2256074
--- /dev/null
+++ b/src/lib/anon-session.ts
@@ -0,0 +1,42 @@
+import { NsecSigner } from '@/providers/NostrProvider/nsec.signer'
+import type { TAccountPointer } from '@/types'
+import { generateSecretKey } from 'nostr-tools'
+
+/** Sentinel pubkey for the anonymous write session (not a real key). */
+export const ANON_ACCOUNT_PUBKEY = '__anon__'
+
+const ANON_SESSION_STORAGE_KEY = 'jumble-anon-session'
+
+export function createAnonAccountPointer(): TAccountPointer {
+ return { pubkey: ANON_ACCOUNT_PUBKEY, signerType: 'anon' }
+}
+
+export function isAnonAccount(account: TAccountPointer | null | undefined): boolean {
+ return account?.signerType === 'anon'
+}
+
+export function isAnonSessionPersisted(): boolean {
+ if (typeof window === 'undefined') return false
+ try {
+ return sessionStorage.getItem(ANON_SESSION_STORAGE_KEY) === '1'
+ } catch {
+ return false
+ }
+}
+
+export function setAnonSessionPersisted(active: boolean): void {
+ if (typeof window === 'undefined') return
+ try {
+ if (active) sessionStorage.setItem(ANON_SESSION_STORAGE_KEY, '1')
+ else sessionStorage.removeItem(ANON_SESSION_STORAGE_KEY)
+ } catch {
+ // ignore quota / private browsing
+ }
+}
+
+/** Fresh nsec signer — new keypair on every call. */
+export function createEphemeralSigner(): NsecSigner {
+ const signer = new NsecSigner()
+ signer.login(generateSecretKey())
+ return signer
+}
diff --git a/src/providers/NostrProvider/index.tsx b/src/providers/NostrProvider/index.tsx
index f6739256..baa9da8c 100644
--- a/src/providers/NostrProvider/index.tsx
+++ b/src/providers/NostrProvider/index.tsx
@@ -84,7 +84,14 @@ import { NostrContext, type TNostrContext } from '@/providers/nostr-context'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useEventCallback } from '@/hooks/use-event-callback'
import { useTranslation } from 'react-i18next'
-import { findStoredAccountForPointer, isSameAccount } from '@/lib/account'
+import { findStoredAccountForPointer, isSameAccount, canAccountSignEvents, canManageIdentityFeatures } from '@/lib/account'
+import {
+ createAnonAccountPointer,
+ createEphemeralSigner,
+ isAnonAccount,
+ isAnonSessionPersisted,
+ setAnonSessionPersisted
+} from '@/lib/anon-session'
import { flushSync } from 'react-dom'
import { toast } from 'sonner'
import { BunkerSigner } from './bunker.signer'
@@ -103,8 +110,10 @@ let nostrSessionRestoreStarted = false
function favoriteRelayUrlsForPublish(
favoriteRelaysEvent: Event | null,
pubkey: string | null,
- relayList: TRelayList | null | undefined
+ relayList: TRelayList | null | undefined,
+ account: TAccountPointer | null
): string[] {
+ if (isAnonAccount(account)) return [...DEFAULT_FAVORITE_RELAYS]
const urlsFromEvent = (): string[] => {
const urls: string[] = []
if (!favoriteRelaysEvent) return urls
@@ -206,6 +215,11 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
return await loginByNostrLoginHash()
}
+ if (isAnonSessionPersisted()) {
+ loginAnon()
+ return
+ }
+
const accounts = storage.getAccounts()
const act = storage.getCurrentAccount() ?? accounts[0] // auto login the first account
if (!act) return
@@ -263,6 +277,12 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
return undefined
}
+ if (isAnonAccount(account)) {
+ setIsAccountSessionHydrating(false)
+ lastNetworkHydrateAccountPubkeyRef.current = null
+ return undefined
+ }
+
const userForcedAccountNetworkHydrate = forceNextAccountNetworkHydrateRef.current
if (userForcedAccountNetworkHydrate) {
forceNextAccountNetworkHydrateRef.current = false
@@ -1248,15 +1268,38 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
setSigner(npubSigner)
}
+ const loginAnon = (): null => {
+ setAnonSessionPersisted(true)
+ clearSessionUiForAccountChange()
+ accountHydrationGenerationRef.current += 1
+ lastNetworkHydrateAccountPubkeyRef.current = null
+
+ const pointer = createAnonAccountPointer()
+ setAccount(pointer)
+ setSigner(null)
+ setNsec(null)
+ setNcryptsec(null)
+ accountForReplaceablesSyncRef.current = pointer
+ client.setSigner(undefined, 'anon')
+ client.pubkey = undefined
+ return null
+ }
+
const switchAccount = async (act: TAccountPointer | null): Promise => {
intentionalNip07ReadOnlyPubkeyRef.current = null
if (!act) {
+ setAnonSessionPersisted(false)
storage.switchAccount(null)
setAccount(null)
setSigner(null)
window.dispatchEvent(new CustomEvent(APP_RESET_TO_LANDING_EVENT))
return null
}
+ if (isAnonAccount(act)) {
+ loginAnon()
+ return null
+ }
+ setAnonSessionPersisted(false)
const result = await loginWithAccountPointer(act, { userInitiatedSwitch: true })
// If loginWithAccountPointer fell back to read-only npub it skips storage.switchAccount.
// Persist the user's intent here so session restore and NIP-07 recovery target this row.
@@ -1489,6 +1532,10 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
act: TAccountPointer,
options?: { userInitiatedSwitch?: boolean }
): Promise => {
+ if (isAnonAccount(act)) {
+ loginAnon()
+ return null
+ }
const fallbackToReadOnlyNpub = (pubkey: string, reason?: unknown): string => {
const pk =
accountPubkeyToHex(pubkey) ??
@@ -1870,6 +1917,16 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
normalizeOpts?: { addClientTag?: boolean }
) => {
const normalizedDraft = normalizeDraftEventTags(draftEvent, normalizeOpts)
+
+ if (isAnonAccount(account)) {
+ const ephemeral = createEphemeralSigner()
+ const event = await ephemeral.signEvent(normalizedDraft)
+ if (!validateEvent(event)) {
+ throw new Error('Event validation failed - invalid signature or format.')
+ }
+ return event as VerifiedEvent
+ }
+
// Add timeout to prevent hanging
const signEventWithTimeout = new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
@@ -1925,12 +1982,23 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
draftEvent: TDraftEvent,
{ minPow = 0, ...options }: TPublishOptions = {}
) => {
- if (!account || !signer || account.signerType === 'npub') {
+ if (!account || account.signerType === 'npub') {
+ throw new LoginRequiredError()
+ }
+ if (!isAnonAccount(account) && !signer) {
throw new LoginRequiredError()
}
- const accountPk = accountPubkeyToHex(account.pubkey)
- await assertSignerMatchesAccountForPublish()
+ const anonSigner = isAnonAccount(account) ? createEphemeralSigner() : null
+ const activeSigner = anonSigner ?? signer
+ if (!activeSigner) {
+ throw new LoginRequiredError()
+ }
+
+ const accountPk = isAnonAccount(account) ? null : accountPubkeyToHex(account.pubkey)
+ if (!isAnonAccount(account)) {
+ await assertSignerMatchesAccountForPublish()
+ }
const normalizeOpts = { addClientTag: options.addClientTag }
const draft = normalizeDraftEventTags(draftEvent, normalizeOpts)
@@ -1944,12 +2012,13 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
})
)
}
+ const publishPubkey = anonSigner ? await anonSigner.getPublicKey() : account.pubkey
const unsignedTemplate = {
kind: draft.kind,
content: draft.content,
tags: draft.tags,
created_at: draft.created_at,
- pubkey: account.pubkey
+ pubkey: publishPubkey
}
if (!validateEvent(unsignedTemplate)) {
throw new Error(t('Invalid event fields'))
@@ -1957,13 +2026,23 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
const id = getEventHash(unsignedTemplate)
event = { ...unsignedTemplate, id, sig: '' }
} else if (minPow > 0) {
- const unsignedEvent = await minePow({ ...draft, pubkey: account.pubkey }, minPow)
- event = await signEvent(unsignedEvent, normalizeOpts)
+ const publishPubkey = anonSigner ? await anonSigner.getPublicKey() : account.pubkey
+ const unsignedEvent = await minePow({ ...draft, pubkey: publishPubkey }, minPow)
+ const normalizedUnsigned = normalizeDraftEventTags(unsignedEvent, normalizeOpts)
+ event = await activeSigner.signEvent(normalizedUnsigned)
+ if (!validateEvent(event)) {
+ throw new Error('Event validation failed - invalid signature or format.')
+ }
} else {
- event = await signEvent(draft, normalizeOpts)
+ const normalizedDraft = normalizeDraftEventTags(draft, normalizeOpts)
+ event = await activeSigner.signEvent(normalizedDraft)
+ if (!validateEvent(event)) {
+ throw new Error('Event validation failed - invalid signature or format.')
+ }
}
if (
+ !isAnonAccount(account) &&
event.kind !== kinds.Application &&
accountPk &&
!hexPubkeysEqual(event.pubkey, accountPk)
@@ -1976,7 +2055,12 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
let publishRelayCandidates: string[] = []
try {
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,
+ account
+ )
publishRelayCandidates = await client.determineTargetRelays(event, {
...options,
favoriteRelayUrls,
@@ -2105,10 +2189,16 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
}
const attemptDelete = async (targetEvent: Event) => {
- if (!signer || account?.signerType === 'npub') {
+ if (!account || account.signerType === 'npub') {
+ return
+ }
+ if (isAnonAccount(account)) {
+ throw new Error(t('accountSwitch.anonCannotDelete'))
+ }
+ if (!signer) {
return
}
- if (account?.pubkey !== targetEvent.pubkey) {
+ if (account.pubkey !== targetEvent.pubkey) {
throw new Error(t('You can only delete your own notes'))
}
@@ -2117,7 +2207,12 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
client.interruptBackgroundQueries()
// Privacy: Only use user's own relays, never connect to "seen on" relays
- const favUrls = favoriteRelayUrlsForPublish(favoriteRelaysEvent, account?.pubkey ?? null, relayList)
+ const favUrls = favoriteRelayUrlsForPublish(
+ favoriteRelaysEvent,
+ account?.pubkey ?? null,
+ relayList,
+ account
+ )
const relays = await client.determineTargetRelays(targetEvent, {
favoriteRelayUrls: favUrls,
blockedRelayUrls: blockedRelayUrlsFromEvent(blockedRelaysEvent)
@@ -2152,10 +2247,20 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
}
const nip04Encrypt = async (pubkey: string, plainText: string) => {
+ if (isAnonAccount(account)) {
+ return createEphemeralSigner().nip04Encrypt(pubkey, plainText)
+ }
return signer?.nip04Encrypt(pubkey, plainText) ?? ''
}
const nip04Decrypt = async (pubkey: string, cipherText: string) => {
+ if (isAnonAccount(account)) {
+ try {
+ return (await createEphemeralSigner().nip04Decrypt(pubkey, cipherText)) ?? ''
+ } catch {
+ return ''
+ }
+ }
if (!signer) return ''
try {
return (await signer.nip04Decrypt(pubkey, cipherText)) ?? ''
@@ -2172,6 +2277,10 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
}
return
}
+ if (isAnonAccount(account)) {
+ if (cb) return await cb()
+ return
+ }
if (!signer) {
setOpenLoginDialog(true)
return
@@ -2404,7 +2513,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
isInitialized,
isAccountSessionHydrating,
isNip07LoginInFlight,
- pubkey: account?.pubkey ?? null,
+ pubkey: isAnonAccount(account) ? null : (account?.pubkey ?? null),
profile,
profileEvent,
relayList,
@@ -2420,7 +2529,9 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
rssFeedListEvent,
account,
accounts,
- canSignEvents: account != null && account.signerType !== 'npub',
+ canSignEvents: canAccountSignEvents(account),
+ isAnonSession: isAnonAccount(account),
+ canManageIdentity: canManageIdentityFeatures(account),
nsec,
ncryptsec,
switchAccount: switchAccountStable,
diff --git a/src/providers/nostr-context.tsx b/src/providers/nostr-context.tsx
index 74be52f0..3261b04d 100644
--- a/src/providers/nostr-context.tsx
+++ b/src/providers/nostr-context.tsx
@@ -37,6 +37,10 @@ export type TNostrContext = {
ncryptsec: string | null
/** True when the session can sign (not read-only npub fallback). */
canSignEvents: boolean
+ /** Anonymous write session: fresh key per publish/sign/auth. */
+ isAnonSession: boolean
+ /** Stable identity features (profile, follow, mute, lists). False in anon write mode. */
+ canManageIdentity: boolean
/** Returns the new session pubkey on success, or `null` if logout / switch failed. */
switchAccount: (account: TAccountPointer | null) => Promise
/** View an account read-only (notifications, relays) without matching the browser extension. */
diff --git a/src/services/client.service.ts b/src/services/client.service.ts
index 55c8421a..a1a0340d 100644
--- a/src/services/client.service.ts
+++ b/src/services/client.service.ts
@@ -35,6 +35,7 @@ import {
SEARCHABLE_RELAY_URLS
} from '@/constants'
+import { createEphemeralSigner } from '@/lib/anon-session'
import { getCacheRelayUrls } from '@/lib/private-relays'
import {
collectReadInboxUrlsFromRelayList,
@@ -690,7 +691,7 @@ class ClientService extends EventTarget {
* is still signing; the batch then finishes and never refetches. Other relays stay on reactive
* `relay.auth()` after `auth-required` to avoid double-sign races with the wider pool.
*/
- if (signer && signerType !== 'npub') {
+ if (signer && signerType !== 'npub' && signerType !== 'anon') {
this.pool.automaticallyAuth = (relayURL: string) => {
const n = normalizeUrl(relayURL) || relayURL
if (!READ_ONLY_RELAY_CONNECT_BOOST_URLS.has(n)) return null
@@ -701,6 +702,13 @@ class ClientService extends EventTarget {
return evt as VerifiedEvent
}
}
+ } else if (signerType === 'anon') {
+ this.pool.automaticallyAuth = () => {
+ return async (event: EventTemplate) => {
+ const ephemeral = createEphemeralSigner()
+ return (await ephemeral.signEvent(event)) as VerifiedEvent
+ }
+ }
} else {
this.pool.automaticallyAuth = undefined
}
diff --git a/src/types/index.d.ts b/src/types/index.d.ts
index 24922b52..a143570f 100644
--- a/src/types/index.d.ts
+++ b/src/types/index.d.ts
@@ -165,7 +165,15 @@ export interface ISigner {
nip04Decrypt: (pubkey: string, cipherText: string) => Promise
}
-export type TSignerType = 'nsec' | 'nip-07' | 'bunker' | 'browser-nsec' | 'ncryptsec' | 'npub'
+export type TSignerType =
+ | 'nsec'
+ | 'nip-07'
+ | 'bunker'
+ | 'browser-nsec'
+ | 'ncryptsec'
+ | 'npub'
+ /** Ephemeral write session: fresh key per sign/publish/auth action. */
+ | 'anon'
export type TAccount = {
pubkey: string