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
+44
View File
@@ -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 before successful response headers, timed IndeeHub rentals, producer receiving
and full app integration. Cached-download interruption does not test the earlier 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. 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.
+2 -1
View File
@@ -486,7 +486,7 @@ function handleBackdropClick() {
} }
function closeSession() { function closeSession() {
if (nostrBridge.showConsent.value) nostrBridge.denyConsent() nostrBridge.cancelPending()
if (document.fullscreenElement) document.exitFullscreen().catch(() => {}) if (document.fullscreenElement) document.exitFullscreen().catch(() => {})
if (isInlinePanel.value) emit('close') if (isInlinePanel.value) emit('close')
else closeRouteSession() else closeRouteSession()
@@ -577,6 +577,7 @@ onMounted(() => {
}) })
onBeforeUnmount(() => { onBeforeUnmount(() => {
nostrBridge.dispose()
if (loadTimeoutId) clearTimeout(loadTimeoutId) if (loadTimeoutId) clearTimeout(loadTimeoutId)
if (autoRetryId) clearTimeout(autoRetryId) if (autoRetryId) clearTimeout(autoRetryId)
if (iframeCheckId) clearTimeout(iframeCheckId) if (iframeCheckId) clearTimeout(iframeCheckId)
@@ -43,3 +43,111 @@ describe('useNostrBridge consent presentation', () => {
expect(bridge.showConsent.value).toBe(false) 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 consentApprovedAt = 0
let consentGeneration = 0 let consentGeneration = 0
let approvedGeneration = 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( function requestConsent(
method: string, method: string,
@@ -64,7 +71,7 @@ export function useNostrBridge(
consentGeneration += 1 consentGeneration += 1
consentRequest.value = { consentRequest.value = {
appName: options.appName(), method, identityLabel, eventKind, content, appName: options.appName(), method, identityLabel, eventKind, content,
resolve, reject, resolve, reject: () => reject(new Error('Signing request denied.')),
} }
consentPhase.value = 'review' consentPhase.value = 'review'
consentError.value = '' consentError.value = ''
@@ -73,6 +80,7 @@ export function useNostrBridge(
} }
function approveConsent(remember: boolean) { function approveConsent(remember: boolean) {
if (consentPhase.value !== 'review' || !consentRequest.value) return
consentRequest.value?.resolve(remember) consentRequest.value?.resolve(remember)
consentApprovedAt = Date.now() consentApprovedAt = Date.now()
approvedGeneration = consentGeneration approvedGeneration = consentGeneration
@@ -82,6 +90,8 @@ export function useNostrBridge(
function denyConsent() { function denyConsent() {
consentGeneration += 1 consentGeneration += 1
consentRequest.value?.reject() consentRequest.value?.reject()
finishErrorPresentation?.()
finishErrorPresentation = undefined
consentRequest.value = null consentRequest.value = null
showConsent.value = false showConsent.value = false
consentPhase.value = 'review' consentPhase.value = 'review'
@@ -104,9 +114,10 @@ export function useNostrBridge(
function finishConsentError(error: unknown) { function finishConsentError(error: unknown) {
consentError.value = error instanceof Error ? error.message : 'The node could not complete this request.' consentError.value = error instanceof Error ? error.message : 'The node could not complete this request.'
consentPhase.value = 'error' 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 if (!event.data || event.data.type !== 'nostr-request') return
const { id, method, params } = event.data const { id, method, params } = event.data
const source = event.source as Window | null const source = event.source as Window | null
@@ -116,28 +127,48 @@ export function useNostrBridge(
!senderMatches(options.appUrl(), event.origin) !senderMatches(options.appUrl(), event.origin)
) return ) 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 storedIdentity = getStoredIdentity()
const identityId = storedIdentity?.id || null const identityId = storedIdentity?.id || null
const identityScope = identityId || 'node-default' const identityScope = identityId || 'node-default'
const identityLabel = storedIdentity?.name || 'Node default identity' const identityLabel = storedIdentity?.name || 'Node default identity'
const origin = event.origin const origin = event.origin
let prompted = false 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 { try {
if (CONSENT_METHODS.has(method)) { if (CONSENT_METHODS.has(method)) {
const key = consentKey(origin, options.appId(), identityScope, method) const key = consentKey(origin, options.appId(), identityScope, method)
if (!hasRememberedConsent(key)) { if (!hasRememberedConsent(key)) {
prompted = true prompted = true
const remember = await requestConsent( const consent = requestConsent(
method, method,
identityLabel, identityLabel,
method === 'signEvent' ? params?.event?.kind : undefined, method === 'signEvent' ? params?.event?.kind : undefined,
method === 'signEvent' ? params?.event?.content : 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 (remember) rememberConsent(key)
} }
} }
if (!stillCurrent()) throw new Error('App or identity changed. Please retry.')
let result: unknown let result: unknown
if (method === 'getPublicKey') { if (method === 'getPublicKey') {
if (storedIdentity?.nostr_pubkey) { if (storedIdentity?.nostr_pubkey) {
@@ -166,8 +197,9 @@ export function useNostrBridge(
} else { } else {
throw new Error(`Unsupported NIP-07 method: ${method}`) 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) source.postMessage({ type: 'nostr-response', id, result }, origin)
if (prompted) void finishConsentSuccess() if (prompted) presentationComplete = finishConsentSuccess()
} catch (err) { } catch (err) {
source.postMessage({ source.postMessage({
type: 'nostr-response', id, 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 { return {
handleNostrRequest, handleNostrRequest,
cancelPending,
dispose,
showConsent, showConsent,
consentRequest, consentRequest,
consentPhase, consentPhase,