Queue native signer requests without losing pending app consent

This commit is contained in:
archipelago
2026-10-06 10:41:58 -04:00
parent 8615e0bc8d
commit 715e86c901
4 changed files with 240 additions and 5 deletions
+2 -1
View File
@@ -486,7 +486,7 @@ function handleBackdropClick() {
}
function closeSession() {
if (nostrBridge.showConsent.value) nostrBridge.denyConsent()
nostrBridge.cancelPending()
if (document.fullscreenElement) document.exitFullscreen().catch(() => {})
if (isInlinePanel.value) emit('close')
else closeRouteSession()
@@ -577,6 +577,7 @@ onMounted(() => {
})
onBeforeUnmount(() => {
nostrBridge.dispose()
if (loadTimeoutId) clearTimeout(loadTimeoutId)
if (autoRetryId) clearTimeout(autoRetryId)
if (iframeCheckId) clearTimeout(iframeCheckId)
@@ -43,3 +43,111 @@ describe('useNostrBridge consent presentation', () => {
expect(bridge.showConsent.value).toBe(false)
})
})
describe('useNostrBridge concurrent requests', () => {
beforeEach(() => {
localStorage.clear(); vi.useFakeTimers(); vi.mocked(rpcClient.call).mockReset()
vi.mocked(rpcClient.call).mockResolvedValue({ id: 'signed-event' })
})
afterEach(() => vi.useRealTimers())
function setup() {
const source = { postMessage: vi.fn() } as unknown as Window
let identity = 'identity-a'
const bridge = useNostrBridge(() => ({ id: identity, name: identity } as never), {
appId: () => 'indeehub', appName: () => 'IndeeHub',
appUrl: () => 'https://node.test/app/indeehub/', frameWindow: () => source,
})
const request = (id: string) => bridge.handleNostrRequest({ source, origin: 'https://node.test', data: {
type: 'nostr-request', id, method: 'signEvent', params: { event: { kind: 27235, content: id } },
} } as MessageEvent)
return { source, bridge, request, setIdentity: (value: string) => { identity = value } }
}
it('queues a second request without replacing the first prompt or dropping either response', async () => {
const { source, bridge, request } = setup()
const first = request('first'), second = request('second')
expect(bridge.consentRequest.value?.content).toBe('first')
bridge.approveConsent(false)
await first
expect(bridge.consentRequest.value?.content).toBe('first')
await vi.advanceTimersByTimeAsync(675)
expect(bridge.consentRequest.value?.content).toBe('second')
bridge.approveConsent(false)
await second
expect(source.postMessage).toHaveBeenCalledWith(expect.objectContaining({ id: 'first', result: { id: 'signed-event' } }), 'https://node.test')
expect(source.postMessage).toHaveBeenCalledWith(expect.objectContaining({ id: 'second', result: { id: 'signed-event' } }), 'https://node.test')
await vi.runAllTimersAsync()
expect(bridge.showConsent.value).toBe(false)
})
it('continues after a denied request without leaving the first promise pending', async () => {
const { source, bridge, request } = setup()
const first = request('denied'), second = request('allowed')
bridge.denyConsent()
await first
await Promise.resolve(); await Promise.resolve()
expect(bridge.consentRequest.value?.content).toBe('allowed')
bridge.approveConsent(false); await second
expect(source.postMessage).toHaveBeenCalledWith(expect.objectContaining({ id: 'denied', error: expect.any(String) }), 'https://node.test')
expect(rpcClient.call).toHaveBeenCalledTimes(1)
await vi.runAllTimersAsync()
})
it('waits for error dismissal before presenting the next request', async () => {
const { bridge, request } = setup()
vi.mocked(rpcClient.call).mockRejectedValueOnce(new Error('Temporary signer failure'))
const first = request('failed'), second = request('next')
bridge.approveConsent(false); await first
expect(bridge.consentPhase.value).toBe('error')
expect(bridge.consentRequest.value?.content).toBe('failed')
bridge.denyConsent()
await Promise.resolve(); await Promise.resolve()
expect(bridge.consentRequest.value?.content).toBe('next')
bridge.approveConsent(false); await second
await vi.runAllTimersAsync()
})
it('cancels active and queued prompts on disposal without signing', async () => {
const { source, bridge, request } = setup()
const first = request('first'), second = request('second')
bridge.dispose()
await Promise.all([first, second, request('after-close')])
expect(rpcClient.call).not.toHaveBeenCalled()
for (const id of ['first', 'second', 'after-close']) {
expect(source.postMessage).toHaveBeenCalledWith(expect.objectContaining({ id, error: expect.any(String) }), 'https://node.test')
}
expect(bridge.showConsent.value).toBe(false)
})
it('rejects an identity change while consent is pending', async () => {
const { source, bridge, request, setIdentity } = setup()
const pending = request('identity-change')
setIdentity('identity-b'); bridge.approveConsent(false); await pending
expect(rpcClient.call).not.toHaveBeenCalled()
expect(source.postMessage).toHaveBeenCalledWith(expect.objectContaining({ error: expect.stringContaining('identity changed') }), 'https://node.test')
bridge.dispose()
})
it('bounds the queue and responds to excess requests instead of silently hanging', async () => {
const { source, bridge, request } = setup()
const pending = Array.from({ length: 20 }, (_, i) => request(`request-${i}`))
expect(source.postMessage).toHaveBeenCalledWith(expect.objectContaining({ error: expect.stringContaining('Too many') }), 'https://node.test')
bridge.dispose(); await Promise.all(pending)
expect(rpcClient.call).not.toHaveBeenCalled()
})
it('rejects a queued request if the selected identity changed before its turn', async () => {
const { source, bridge, request, setIdentity } = setup()
const first = request('first'), second = request('stale-queued')
bridge.approveConsent(false); await first
setIdentity('identity-b')
await vi.runAllTimersAsync(); await second
expect(rpcClient.call).toHaveBeenCalledTimes(1)
expect(source.postMessage).toHaveBeenCalledWith(expect.objectContaining({ id: 'stale-queued', error: expect.stringContaining('identity changed') }), 'https://node.test')
})
it('allows a retained app session to sign again after its previous prompts were cancelled', async () => {
const { bridge, request } = setup()
const cancelled = request('cancelled')
bridge.cancelPending(); await cancelled
await Promise.resolve(); await Promise.resolve()
const reopened = request('reopened')
expect(bridge.consentRequest.value?.content).toBe('reopened')
bridge.approveConsent(false); await reopened
expect(rpcClient.call).toHaveBeenCalledTimes(1)
await vi.runAllTimersAsync()
})
})
@@ -53,6 +53,13 @@ export function useNostrBridge(
let consentApprovedAt = 0
let consentGeneration = 0
let approvedGeneration = 0
let sessionGeneration = 0
let disposed = false
let draining = false
let presentationComplete: Promise<void> = Promise.resolve()
let finishErrorPresentation: (() => void) | undefined
type RequestScope = { appId: string; identityId: string | null; session: number }
const requests: Array<{ event: MessageEvent; resolve: () => void; scope: RequestScope }> = []
function requestConsent(
method: string,
@@ -64,7 +71,7 @@ export function useNostrBridge(
consentGeneration += 1
consentRequest.value = {
appName: options.appName(), method, identityLabel, eventKind, content,
resolve, reject,
resolve, reject: () => reject(new Error('Signing request denied.')),
}
consentPhase.value = 'review'
consentError.value = ''
@@ -73,6 +80,7 @@ export function useNostrBridge(
}
function approveConsent(remember: boolean) {
if (consentPhase.value !== 'review' || !consentRequest.value) return
consentRequest.value?.resolve(remember)
consentApprovedAt = Date.now()
approvedGeneration = consentGeneration
@@ -82,6 +90,8 @@ export function useNostrBridge(
function denyConsent() {
consentGeneration += 1
consentRequest.value?.reject()
finishErrorPresentation?.()
finishErrorPresentation = undefined
consentRequest.value = null
showConsent.value = false
consentPhase.value = 'review'
@@ -104,9 +114,10 @@ export function useNostrBridge(
function finishConsentError(error: unknown) {
consentError.value = error instanceof Error ? error.message : 'The node could not complete this request.'
consentPhase.value = 'error'
presentationComplete = new Promise(resolve => { finishErrorPresentation = resolve })
}
async function handleNostrRequest(event: MessageEvent) {
async function processNostrRequest(event: MessageEvent, scope: RequestScope) {
if (!event.data || event.data.type !== 'nostr-request') return
const { id, method, params } = event.data
const source = event.source as Window | null
@@ -116,28 +127,48 @@ export function useNostrBridge(
!senderMatches(options.appUrl(), event.origin)
) return
if (disposed) {
source.postMessage({ type: 'nostr-response', id, error: 'App session closed.' }, event.origin)
return
}
if (scope.appId !== options.appId() || scope.identityId !== (getStoredIdentity()?.id || null)
|| scope.session !== sessionGeneration) {
source.postMessage({ type: 'nostr-response', id, error: 'App or identity changed. Please retry.' }, event.origin)
return
}
const requestedSession = sessionGeneration
const requestedApp = options.appId()
const storedIdentity = getStoredIdentity()
const identityId = storedIdentity?.id || null
const identityScope = identityId || 'node-default'
const identityLabel = storedIdentity?.name || 'Node default identity'
const origin = event.origin
let prompted = false
let promptGeneration: number | undefined
const stillCurrent = () => !disposed && requestedSession === sessionGeneration && requestedApp === options.appId()
&& source === options.frameWindow() && senderMatches(options.appUrl(), origin)
&& (getStoredIdentity()?.id || null) === identityId
&& (promptGeneration === undefined || promptGeneration === consentGeneration)
try {
if (CONSENT_METHODS.has(method)) {
const key = consentKey(origin, options.appId(), identityScope, method)
if (!hasRememberedConsent(key)) {
prompted = true
const remember = await requestConsent(
const consent = requestConsent(
method,
identityLabel,
method === 'signEvent' ? params?.event?.kind : undefined,
method === 'signEvent' ? params?.event?.content : undefined,
)
promptGeneration = consentGeneration
const remember = await consent
if (!stillCurrent()) throw new Error('App or identity changed. Please retry.')
if (remember) rememberConsent(key)
}
}
if (!stillCurrent()) throw new Error('App or identity changed. Please retry.')
let result: unknown
if (method === 'getPublicKey') {
if (storedIdentity?.nostr_pubkey) {
@@ -166,8 +197,9 @@ export function useNostrBridge(
} else {
throw new Error(`Unsupported NIP-07 method: ${method}`)
}
if (!stillCurrent()) throw new Error('App or identity changed. Please retry.')
source.postMessage({ type: 'nostr-response', id, result }, origin)
if (prompted) void finishConsentSuccess()
if (prompted) presentationComplete = finishConsentSuccess()
} catch (err) {
source.postMessage({
type: 'nostr-response', id,
@@ -177,8 +209,58 @@ export function useNostrBridge(
}
}
async function drainRequests() {
if (draining) return
draining = true
try {
while (requests.length) {
const next = requests.shift()!
try { await processNostrRequest(next.event, next.scope) } catch {
// A removed/navigated frame can reject postMessage; drain the remaining requests.
} finally { next.resolve() }
// Preserve the approved success animation and never replace a prompt.
await presentationComplete
}
} finally { draining = false }
}
function handleNostrRequest(event: MessageEvent): Promise<void> {
if (!event.data || event.data.type !== 'nostr-request'
|| !event.source || event.source !== options.frameWindow()
|| !senderMatches(options.appUrl(), event.origin)) return Promise.resolve()
if (disposed || requests.length >= 16) {
(event.source as Window).postMessage({ type: 'nostr-response', id: event.data.id,
error: disposed ? 'App session closed.' : 'Too many signing requests. Please retry.' }, event.origin)
return Promise.resolve()
}
return new Promise(resolve => {
requests.push({ event, resolve, scope: { appId: options.appId(),
identityId: getStoredIdentity()?.id || null, session: sessionGeneration } })
void drainRequests()
})
}
function cancelPending() {
sessionGeneration += 1
denyConsent()
for (const next of requests.splice(0)) {
try {
(next.event.source as Window)?.postMessage({ type: 'nostr-response', id: next.event.data.id,
error: 'App session closed.' }, next.event.origin)
} catch { /* frame already removed */ }
next.resolve()
}
}
function dispose() {
disposed = true
cancelPending()
}
return {
handleNostrRequest,
cancelPending,
dispose,
showConsent,
consentRequest,
consentPhase,