From 715e86c901884d3048fd20a7c5603ffe52402e75 Mon Sep 17 00:00:00 2001 From: archipelago Date: Tue, 6 Oct 2026 10:41:58 -0400 Subject: [PATCH] Queue native signer requests without losing pending app consent --- docs/post-1.9.0-progress-20261006.md | 44 +++++++ neode-ui/src/views/AppSession.vue | 3 +- .../__tests__/useNostrBridge.test.ts | 108 ++++++++++++++++++ .../src/views/appSession/useNostrBridge.ts | 90 ++++++++++++++- 4 files changed, 240 insertions(+), 5 deletions(-) diff --git a/docs/post-1.9.0-progress-20261006.md b/docs/post-1.9.0-progress-20261006.md index 3a1dab25..2e593a93 100644 --- a/docs/post-1.9.0-progress-20261006.md +++ b/docs/post-1.9.0-progress-20261006.md @@ -547,3 +547,47 @@ with two successful purchases; do not reset it or repay for these tests. before successful response headers, timed IndeeHub rentals, producer receiving and full app integration. Cached-download interruption does not test the earlier payment boundary. No new release/catalog/app image was published by this work. + +## IndeeHub implementation continued after missing-source report + +The operator's observation was correct: Yaya's IndeeHub image had no Archipelago +source. Only the Archy sharing backend had been deployed. No IndeeHub app image +has been published or deployed during this continuation. + +IndeeHub branch `work/archipelago-auth-and-sharing` now contains: + +- `b52407c`: verified signed-offer discovery, deterministic revisions and deletion + tombstones, persistent browser cache, explicit relay completion/error handling, + signed publication retry outbox, and configured Archipelago browsing/search. + **74 tests passed**. Built-browser checks passed at 390/1440px for signed titles, + forged rejection, source isolation, mobile/desktop search, cache outage/retry, + displayed price and rejection of legacy payment/playback. Existing library and + session-restoration browser checks passed again on that bundle. +- `38ed9b6`: timed-rental access policy and PostgreSQL row-locking store. + **26 tests passed**, including a real isolated PostgreSQL instance and 24 + concurrent first-play requests. Window, expiry, buyer/offer/hash bindings, + unpaid/revoked rejection and persisted clock-rollback protection were tested. + Backend build passed. Temporary DB container, volume and credentials removed. + +These are component checkpoints, not complete paid-video acceptance. Backstage +project authorization/publication transaction, node offer registration, correlated +receiving/payment recovery and actual FIPS timed playback are still open. The +browser qualification bundle contains an intercepted test relay and **must not be +deployed**. Its playback guard is deliberately disabled until those paths exist. +See the IndeeHub repository's `docs/archipelago-catalog-implementation.md`. + +While tracing producer signing, source inspection found an existing dashboard +bridge defect: concurrent consent requests overwrite the one stored promise, +leaving the earlier app request waiting indefinitely. A queue/cancellation fix is +being qualified in this worktree. It preserves the approved signing animation, +checks identity/session changes, bounds the queue and cancels pending work on +close/unmount. This is a confirmed source defect, not yet proof of the physical +companion grey-screen cause. It is not deployed at this checkpoint. + +Signer queue checkpoint: **17 focused tests across five files pass**, covering +concurrent requests, denial, error dismissal, closure, queue bounds, identity +changes, reopening a retained session, existing consent scoping, tab signing and +the approved consent presentation. Dashboard typecheck passes. Logs: +`/tmp/archy-signer-queue-related-tests.log`, +`/tmp/archy-signer-queue-typecheck.log`. Production UI build/deployment still +pending; physical companion causality remains unverified. diff --git a/neode-ui/src/views/AppSession.vue b/neode-ui/src/views/AppSession.vue index 7aab7899..c88c08a4 100644 --- a/neode-ui/src/views/AppSession.vue +++ b/neode-ui/src/views/AppSession.vue @@ -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) diff --git a/neode-ui/src/views/appSession/__tests__/useNostrBridge.test.ts b/neode-ui/src/views/appSession/__tests__/useNostrBridge.test.ts index 366c2cb4..6753975f 100644 --- a/neode-ui/src/views/appSession/__tests__/useNostrBridge.test.ts +++ b/neode-ui/src/views/appSession/__tests__/useNostrBridge.test.ts @@ -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() + }) + +}) diff --git a/neode-ui/src/views/appSession/useNostrBridge.ts b/neode-ui/src/views/appSession/useNostrBridge.ts index a2187134..9363629f 100644 --- a/neode-ui/src/views/appSession/useNostrBridge.ts +++ b/neode-ui/src/views/appSession/useNostrBridge.ts @@ -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 = 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 { + 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,