Queue native signer requests without losing pending app consent
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user